mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat: Add option to restrict credential usage in http request node (#17583)
This commit is contained in:
@@ -441,11 +441,11 @@ describe('AI Assistant Credential Help', () => {
|
||||
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
|
||||
ndv.getters.copyInput().should('not.exist');
|
||||
credentialsModal.getters.oauthConnectButton().should('have.length', 1);
|
||||
credentialsModal.getters.credentialInputs().should('have.length', 0);
|
||||
credentialsModal.getters.credentialInputs().should('have.length', 2);
|
||||
aiAssistant.getters.credentialEditAssistantButton().should('not.exist');
|
||||
|
||||
credentialsModal.getters.credentialAuthTypeRadioButtons().eq(1).click();
|
||||
credentialsModal.getters.credentialInputs().should('have.length', 3);
|
||||
credentialsModal.getters.credentialInputs().should('have.length', 4);
|
||||
aiAssistant.getters.credentialEditAssistantButton().should('exist');
|
||||
});
|
||||
|
||||
@@ -473,7 +473,7 @@ describe('AI Assistant Credential Help', () => {
|
||||
wf.getters.nodeCredentialsCreateOption().click();
|
||||
ndv.getters.copyInput().should('not.exist');
|
||||
credentialsModal.getters.oauthConnectButton().should('have.length', 1);
|
||||
credentialsModal.getters.credentialInputs().should('have.length', 1);
|
||||
credentialsModal.getters.credentialInputs().should('have.length', 2);
|
||||
aiAssistant.getters.credentialEditAssistantButton().should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -443,6 +443,135 @@ describe('LoadNodesAndCredentials', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldAddDomainRestrictions', () => {
|
||||
let instance: LoadNodesAndCredentials;
|
||||
|
||||
beforeEach(() => {
|
||||
instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock());
|
||||
});
|
||||
it('should return true for credentials with authenticate property', () => {
|
||||
const credential = {
|
||||
name: 'testCredential',
|
||||
displayName: 'Test Credential',
|
||||
authenticate: {},
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const result = (instance as any).shouldAddDomainRestrictions(credential);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for credentials with genericAuth set to true', () => {
|
||||
const credential = {
|
||||
name: 'testCredential',
|
||||
displayName: 'Test Credential',
|
||||
genericAuth: true,
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const result = (instance as any).shouldAddDomainRestrictions(credential);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for credentials extending oAuth2Api', () => {
|
||||
const credential = {
|
||||
name: 'testCredential',
|
||||
displayName: 'Test Credential',
|
||||
extends: ['oAuth2Api'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const result = (instance as any).shouldAddDomainRestrictions(credential);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for credentials extending oAuth1Api', () => {
|
||||
const credential = {
|
||||
name: 'testCredential',
|
||||
displayName: 'Test Credential',
|
||||
extends: ['oAuth1Api'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const result = (instance as any).shouldAddDomainRestrictions(credential);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for credentials extending googleOAuth2Api', () => {
|
||||
const credential = {
|
||||
name: 'testCredential',
|
||||
displayName: 'Test Credential',
|
||||
extends: ['googleOAuth2Api'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const result = (instance as any).shouldAddDomainRestrictions(credential);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when extending multiple APIs including OAuth', () => {
|
||||
const credential = {
|
||||
name: 'testCredential',
|
||||
displayName: 'Test Credential',
|
||||
extends: ['someOtherApi', 'oAuth2Api', 'anotherApi'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const result = (instance as any).shouldAddDomainRestrictions(credential);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for credentials without authenticate, genericAuth, or OAuth extensions', () => {
|
||||
const credential = {
|
||||
name: 'testCredential',
|
||||
displayName: 'Test Credential',
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const result = (instance as any).shouldAddDomainRestrictions(credential);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for credentials with extends that does not include OAuth types', () => {
|
||||
const credential = {
|
||||
name: 'testCredential',
|
||||
displayName: 'Test Credential',
|
||||
extends: ['someOtherApi', 'anotherApi'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const result = (instance as any).shouldAddDomainRestrictions(credential);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle LoadedClass credential objects with type property', () => {
|
||||
const credential = {
|
||||
type: {
|
||||
name: 'testCredential',
|
||||
displayName: 'Test Credential',
|
||||
authenticate: {},
|
||||
properties: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = (instance as any).shouldAddDomainRestrictions(credential);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for LoadedClass credential objects without auth-related properties', () => {
|
||||
const credential = {
|
||||
type: {
|
||||
name: 'testCredential',
|
||||
displayName: 'Test Credential',
|
||||
properties: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = (instance as any).shouldAddDomainRestrictions(credential);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupHotReload', () => {
|
||||
let instance: LoadNodesAndCredentials;
|
||||
|
||||
|
||||
@@ -390,6 +390,19 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||
decryptedData.oauthTokenData = decryptedDataOriginal.oauthTokenData;
|
||||
}
|
||||
|
||||
if (
|
||||
decryptedData.allowedHttpRequestDomains === undefined &&
|
||||
decryptedDataOriginal.allowedHttpRequestDomains !== undefined
|
||||
) {
|
||||
decryptedData.allowedHttpRequestDomains = decryptedDataOriginal.allowedHttpRequestDomains;
|
||||
}
|
||||
if (
|
||||
decryptedData.allowedDomains === undefined &&
|
||||
decryptedDataOriginal.allowedDomains !== undefined
|
||||
) {
|
||||
decryptedData.allowedDomains = decryptedDataOriginal.allowedDomains;
|
||||
}
|
||||
|
||||
const canUseExternalSecrets = await this.credentialCanUseExternalSecrets(credential);
|
||||
const additionalKeys = getAdditionalKeys(additionalData, mode, null, {
|
||||
secretsEnabled: canUseExternalSecrets,
|
||||
|
||||
@@ -346,23 +346,41 @@ export class LoadNodesAndCredentials {
|
||||
name: `${packageName}.${name}`,
|
||||
})),
|
||||
);
|
||||
this.types.credentials = this.types.credentials.concat(
|
||||
types.credentials.map(({ supportedNodes, ...rest }) => ({
|
||||
...rest,
|
||||
|
||||
const processedCredentials = types.credentials.map((credential) => {
|
||||
if (this.shouldAddDomainRestrictions(credential)) {
|
||||
const clonedCredential = { ...credential };
|
||||
clonedCredential.properties = this.injectDomainRestrictionFields([
|
||||
...(clonedCredential.properties ?? []),
|
||||
]);
|
||||
return {
|
||||
...clonedCredential,
|
||||
supportedNodes:
|
||||
loader instanceof PackageDirectoryLoader
|
||||
? credential.supportedNodes?.map((nodeName) => `${loader.packageName}.${nodeName}`)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...credential,
|
||||
supportedNodes:
|
||||
loader instanceof PackageDirectoryLoader
|
||||
? supportedNodes?.map((nodeName) => `${loader.packageName}.${nodeName}`)
|
||||
? credential.supportedNodes?.map((nodeName) => `${loader.packageName}.${nodeName}`)
|
||||
: undefined,
|
||||
})),
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
// Nodes and credentials that have been loaded immediately
|
||||
for (const nodeTypeName in loader.nodeTypes) {
|
||||
this.loaded.nodes[`${packageName}.${nodeTypeName}`] = loader.nodeTypes[nodeTypeName];
|
||||
}
|
||||
this.types.credentials = this.types.credentials.concat(processedCredentials);
|
||||
|
||||
// Add domain restriction fields to loaded credentials
|
||||
for (const credentialTypeName in loader.credentialTypes) {
|
||||
this.loaded.credentials[credentialTypeName] = loader.credentialTypes[credentialTypeName];
|
||||
const credentialType = loader.credentialTypes[credentialTypeName];
|
||||
if (this.shouldAddDomainRestrictions(credentialType)) {
|
||||
// Access properties through the type field
|
||||
credentialType.type.properties = this.injectDomainRestrictionFields([
|
||||
...(credentialType.type.properties ?? []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const type in known.nodes) {
|
||||
@@ -604,4 +622,67 @@ export class LoadNodesAndCredentials {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldAddDomainRestrictions(
|
||||
credential: ICredentialType | LoadedClass<ICredentialType>,
|
||||
): boolean {
|
||||
// Handle both credential types by extracting the actual ICredentialType
|
||||
const credentialType = 'type' in credential ? credential.type : credential;
|
||||
|
||||
return (
|
||||
credentialType.authenticate !== undefined ||
|
||||
credentialType.genericAuth === true ||
|
||||
(Array.isArray(credentialType.extends) &&
|
||||
(credentialType.extends.includes('oAuth2Api') ||
|
||||
credentialType.extends.includes('oAuth1Api') ||
|
||||
credentialType.extends.includes('googleOAuth2Api')))
|
||||
);
|
||||
}
|
||||
|
||||
private injectDomainRestrictionFields(properties: INodeProperties[]): INodeProperties[] {
|
||||
// Check if fields already exist to avoid duplicates
|
||||
if (properties.some((prop) => prop.name === 'allowedHttpRequestDomains')) {
|
||||
return properties;
|
||||
}
|
||||
const domainFields: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Allowed HTTP Request Domains',
|
||||
name: 'allowedHttpRequestDomains',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'All',
|
||||
value: 'all',
|
||||
description: 'Allow all requests when used in the HTTP Request node',
|
||||
},
|
||||
{
|
||||
name: 'Specific Domains',
|
||||
value: 'domains',
|
||||
description: 'Restrict requests to specific domains',
|
||||
},
|
||||
{
|
||||
name: 'None',
|
||||
value: 'none',
|
||||
description: 'Block all requests when used in the HTTP Request node',
|
||||
},
|
||||
],
|
||||
default: 'all',
|
||||
description: 'Control which domains this credential can be used with in HTTP Request nodes',
|
||||
},
|
||||
{
|
||||
displayName: 'Allowed Domains',
|
||||
name: 'allowedDomains',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'example.com, *.subdomain.com',
|
||||
description: 'Comma-separated list of allowed domains (supports wildcards with *)',
|
||||
displayOptions: {
|
||||
show: {
|
||||
allowedHttpRequestDomains: ['domains'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
return [...properties, ...domainFields];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
JsonObject,
|
||||
IHttpRequestMethods,
|
||||
IRequestOptions,
|
||||
ICredentialDataDecryptedObject,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
NodeApiError,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
sleep,
|
||||
removeCircularRefs,
|
||||
NodeConnectionTypes,
|
||||
isDomainAllowed,
|
||||
} from 'n8n-workflow';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
@@ -676,6 +678,51 @@ export class HttpRequestV1 implements INodeType {
|
||||
const options = this.getNodeParameter('options', itemIndex, {});
|
||||
const url = this.getNodeParameter('url', itemIndex) as string;
|
||||
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Invalid URL: ${url}. URL must start with "http" or "https".`,
|
||||
);
|
||||
}
|
||||
|
||||
const checkDomainRestrictions = async (
|
||||
credentialData: ICredentialDataDecryptedObject,
|
||||
url: string,
|
||||
credentialType?: string,
|
||||
) => {
|
||||
if (credentialData.allowedHttpRequestDomains === 'domains') {
|
||||
const allowedDomains = credentialData.allowedDomains as string;
|
||||
|
||||
if (!allowedDomains || allowedDomains.trim() === '') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'No allowed domains specified. Configure allowed domains or change restriction setting.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDomainAllowed(url, { allowedDomains })) {
|
||||
const credentialInfo = credentialType ? ` (${credentialType})` : '';
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Domain not allowed: This credential${credentialInfo} is restricted from accessing ${url}. ` +
|
||||
`Only the following domains are allowed: ${allowedDomains}`,
|
||||
);
|
||||
}
|
||||
} else if (credentialData.allowedHttpRequestDomains === 'none') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'This credential is configured to prevent use within an HTTP Request node',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (httpBasicAuth) await checkDomainRestrictions(httpBasicAuth, url);
|
||||
if (httpDigestAuth) await checkDomainRestrictions(httpDigestAuth, url);
|
||||
if (httpHeaderAuth) await checkDomainRestrictions(httpHeaderAuth, url);
|
||||
if (httpQueryAuth) await checkDomainRestrictions(httpQueryAuth, url);
|
||||
if (oAuth1Api) await checkDomainRestrictions(oAuth1Api, url);
|
||||
if (oAuth2Api) await checkDomainRestrictions(oAuth2Api, url);
|
||||
|
||||
if (
|
||||
itemIndex > 0 &&
|
||||
(options.batchSize as number) >= 0 &&
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IHttpRequestMethods,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
sleep,
|
||||
removeCircularRefs,
|
||||
NodeConnectionTypes,
|
||||
isDomainAllowed,
|
||||
} from 'n8n-workflow';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
@@ -721,6 +723,67 @@ export class HttpRequestV2 implements INodeType {
|
||||
const options = this.getNodeParameter('options', itemIndex, {});
|
||||
const url = this.getNodeParameter('url', itemIndex) as string;
|
||||
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Invalid URL: ${url}. URL must start with "http" or "https".`,
|
||||
);
|
||||
}
|
||||
|
||||
const checkDomainRestrictions = async (
|
||||
credentialData: ICredentialDataDecryptedObject,
|
||||
url: string,
|
||||
credentialType?: string,
|
||||
) => {
|
||||
if (credentialData.allowedHttpRequestDomains === 'domains') {
|
||||
const allowedDomains = credentialData.allowedDomains as string;
|
||||
|
||||
if (!allowedDomains || allowedDomains.trim() === '') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'No allowed domains specified. Configure allowed domains or change restriction setting.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDomainAllowed(url, { allowedDomains })) {
|
||||
const credentialInfo = credentialType ? ` (${credentialType})` : '';
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Domain not allowed: This credential${credentialInfo} is restricted from accessing ${url}. ` +
|
||||
`Only the following domains are allowed: ${allowedDomains}`,
|
||||
);
|
||||
}
|
||||
} else if (credentialData.allowedHttpRequestDomains === 'none') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'This credential is configured to prevent use within an HTTP Request node',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (httpBasicAuth) await checkDomainRestrictions(httpBasicAuth, url);
|
||||
if (httpBearerAuth) await checkDomainRestrictions(httpBearerAuth, url);
|
||||
if (httpDigestAuth) await checkDomainRestrictions(httpDigestAuth, url);
|
||||
if (httpHeaderAuth) await checkDomainRestrictions(httpHeaderAuth, url);
|
||||
if (httpQueryAuth) await checkDomainRestrictions(httpQueryAuth, url);
|
||||
if (oAuth1Api) await checkDomainRestrictions(oAuth1Api, url);
|
||||
if (oAuth2Api) await checkDomainRestrictions(oAuth2Api, url);
|
||||
|
||||
if (nodeCredentialType) {
|
||||
try {
|
||||
const credentialData = await this.getCredentials(nodeCredentialType, itemIndex);
|
||||
await checkDomainRestrictions(credentialData, url, nodeCredentialType);
|
||||
} catch (error) {
|
||||
if (
|
||||
error.message?.includes('Domain not allowed') ||
|
||||
error.message?.includes('configured to prevent') ||
|
||||
error.message?.includes('No allowed domains specified')
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
itemIndex > 0 &&
|
||||
(options.batchSize as number) >= 0 &&
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
JsonObject,
|
||||
IRequestOptions,
|
||||
IHttpRequestMethods,
|
||||
ICredentialDataDecryptedObject,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
BINARY_ENCODING,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
jsonParse,
|
||||
removeCircularRefs,
|
||||
sleep,
|
||||
isDomainAllowed,
|
||||
} from 'n8n-workflow';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
@@ -180,6 +182,70 @@ export class HttpRequestV3 implements INodeType {
|
||||
nodeCredentialType = this.getNodeParameter('nodeCredentialType', itemIndex) as string;
|
||||
}
|
||||
|
||||
const url = this.getNodeParameter('url', itemIndex) as string;
|
||||
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Invalid URL: ${url}. URL must start with "http" or "https".`,
|
||||
);
|
||||
}
|
||||
|
||||
const checkDomainRestrictions = async (
|
||||
credentialData: ICredentialDataDecryptedObject,
|
||||
url: string,
|
||||
credentialType?: string,
|
||||
) => {
|
||||
if (credentialData.allowedHttpRequestDomains === 'domains') {
|
||||
const allowedDomains = credentialData.allowedDomains as string;
|
||||
|
||||
if (!allowedDomains || allowedDomains.trim() === '') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'No allowed domains specified. Configure allowed domains or change restriction setting.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDomainAllowed(url, { allowedDomains })) {
|
||||
const credentialInfo = credentialType ? ` (${credentialType})` : '';
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Domain not allowed: This credential${credentialInfo} is restricted from accessing ${url}. ` +
|
||||
`Only the following domains are allowed: ${allowedDomains}`,
|
||||
);
|
||||
}
|
||||
} else if (credentialData.allowedHttpRequestDomains === 'none') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'This credential is configured to prevent use within an HTTP Request node',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (httpBasicAuth) await checkDomainRestrictions(httpBasicAuth, url);
|
||||
if (httpBearerAuth) await checkDomainRestrictions(httpBearerAuth, url);
|
||||
if (httpDigestAuth) await checkDomainRestrictions(httpDigestAuth, url);
|
||||
if (httpHeaderAuth) await checkDomainRestrictions(httpHeaderAuth, url);
|
||||
if (httpQueryAuth) await checkDomainRestrictions(httpQueryAuth, url);
|
||||
if (httpCustomAuth) await checkDomainRestrictions(httpCustomAuth, url);
|
||||
if (oAuth1Api) await checkDomainRestrictions(oAuth1Api, url);
|
||||
if (oAuth2Api) await checkDomainRestrictions(oAuth2Api, url);
|
||||
|
||||
if (nodeCredentialType) {
|
||||
try {
|
||||
const credentialData = await this.getCredentials(nodeCredentialType, itemIndex);
|
||||
await checkDomainRestrictions(credentialData, url, nodeCredentialType);
|
||||
} catch (error) {
|
||||
if (
|
||||
error.message?.includes('Domain not allowed') ||
|
||||
error.message?.includes('configured to prevent') ||
|
||||
error.message?.includes('No allowed domains specified')
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const provideSslCertificates = this.getNodeParameter(
|
||||
'provideSslCertificates',
|
||||
itemIndex,
|
||||
@@ -257,8 +323,6 @@ export class HttpRequestV3 implements INodeType {
|
||||
|
||||
responseFileName = response?.response?.outputPropertyName;
|
||||
|
||||
const url = this.getNodeParameter('url', itemIndex) as string;
|
||||
|
||||
const responseFormat = response?.response?.responseFormat || 'autodetect';
|
||||
|
||||
fullResponse = response?.response?.fullResponse || false;
|
||||
|
||||
@@ -44,6 +44,7 @@ export {
|
||||
randomString,
|
||||
isSafeObjectProperty,
|
||||
setSafeObjectProperty,
|
||||
isDomainAllowed,
|
||||
} from './utils';
|
||||
export {
|
||||
isINodeProperties,
|
||||
|
||||
@@ -358,3 +358,43 @@ export function setSafeObjectProperty(
|
||||
target[property] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function isDomainAllowed(
|
||||
urlString: string,
|
||||
options: {
|
||||
allowedDomains: string;
|
||||
},
|
||||
): boolean {
|
||||
if (!options.allowedDomains || options.allowedDomains.trim() === '') {
|
||||
return true; // If no restrictions are set, allow all domains
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
const hostname = url.hostname;
|
||||
|
||||
const allowedDomainsList = options.allowedDomains
|
||||
.split(',')
|
||||
.map((domain) => domain.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const allowedDomain of allowedDomainsList) {
|
||||
// Handle wildcard domains (*.example.com)
|
||||
if (allowedDomain.startsWith('*.')) {
|
||||
const domainSuffix = allowedDomain.substring(2); // Remove the *. part
|
||||
if (hostname.endsWith(domainSuffix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Exact match
|
||||
else if (hostname === allowedDomain) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
// If URL parsing fails, deny access to be safe
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
jsonParse,
|
||||
jsonStringify,
|
||||
deepCopy,
|
||||
isDomainAllowed,
|
||||
isObjectEmpty,
|
||||
fileTypeFromMimeType,
|
||||
randomInt,
|
||||
@@ -461,3 +462,133 @@ describe('sleepWithAbort', () => {
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDomainAllowed', () => {
|
||||
describe('when no allowed domains are specified', () => {
|
||||
it('should allow all domains when allowedDomains is empty', () => {
|
||||
expect(isDomainAllowed('https://example.com', { allowedDomains: '' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow all domains when allowedDomains contains only whitespace', () => {
|
||||
expect(isDomainAllowed('https://example.com', { allowedDomains: ' ' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('in strict validation mode', () => {
|
||||
it('should allow exact domain matches', () => {
|
||||
expect(
|
||||
isDomainAllowed('https://example.com', {
|
||||
allowedDomains: 'example.com',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow domains from a comma-separated list', () => {
|
||||
expect(
|
||||
isDomainAllowed('https://example.com', {
|
||||
allowedDomains: 'test.com,example.com,other.org',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle whitespace in allowed domains list', () => {
|
||||
expect(
|
||||
isDomainAllowed('https://example.com', {
|
||||
allowedDomains: ' test.com , example.com , other.org ',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should block non-matching domains', () => {
|
||||
expect(
|
||||
isDomainAllowed('https://malicious.com', {
|
||||
allowedDomains: 'example.com',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should block subdomains not set', () => {
|
||||
expect(
|
||||
isDomainAllowed('https://sub.example.com', {
|
||||
allowedDomains: 'example.com',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with wildcard domains', () => {
|
||||
it('should allow matching wildcard domains', () => {
|
||||
expect(
|
||||
isDomainAllowed('https://test.example.com', {
|
||||
allowedDomains: '*.example.com',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow nested subdomains with wildcards', () => {
|
||||
expect(
|
||||
isDomainAllowed('https://deep.nested.example.com', {
|
||||
allowedDomains: '*.example.com',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should block non-matching domains with wildcards', () => {
|
||||
expect(
|
||||
isDomainAllowed('https://example.org', {
|
||||
allowedDomains: '*.example.com',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle invalid URLs safely', () => {
|
||||
expect(
|
||||
isDomainAllowed('not-a-valid-url', {
|
||||
allowedDomains: 'example.com',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle URLs with ports', () => {
|
||||
expect(
|
||||
isDomainAllowed('https://example.com:8080/path', {
|
||||
allowedDomains: 'example.com',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle URLs with authentication', () => {
|
||||
expect(
|
||||
isDomainAllowed('https://user:pass@example.com', {
|
||||
allowedDomains: 'example.com',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle URLs with query parameters and fragments', () => {
|
||||
expect(
|
||||
isDomainAllowed('https://example.com/path?query=test#fragment', {
|
||||
allowedDomains: 'example.com',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle IP addresses', () => {
|
||||
expect(
|
||||
isDomainAllowed('https://192.168.1.1', {
|
||||
allowedDomains: '192.168.1.1',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty URLs', () => {
|
||||
expect(
|
||||
isDomainAllowed('', {
|
||||
allowedDomains: 'example.com',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user