feat: Add option to restrict credential usage in http request node (#17583)

This commit is contained in:
Jon
2025-08-28 17:35:14 +01:00
committed by GitHub
parent 93e08d8735
commit f7f70f241e
10 changed files with 585 additions and 16 deletions

View File

@@ -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');
});
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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];
}
}

View File

@@ -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 &&

View File

@@ -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 &&

View File

@@ -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;

View File

@@ -44,6 +44,7 @@ export {
randomString,
isSafeObjectProperty,
setSafeObjectProperty,
isDomainAllowed,
} from './utils';
export {
isINodeProperties,

View File

@@ -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;
}
}

View File

@@ -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);
});
});
});