From f7f70f241e2eac2b98720b8d4921f05009b12fe6 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 28 Aug 2025 17:35:14 +0100 Subject: [PATCH] feat: Add option to restrict credential usage in http request node (#17583) --- cypress/e2e/45-ai-assistant.cy.ts | 6 +- .../load-nodes-and-credentials.test.ts | 129 +++++++++++++++++ packages/cli/src/credentials-helper.ts | 13 ++ .../cli/src/load-nodes-and-credentials.ts | 103 ++++++++++++-- .../HttpRequest/V1/HttpRequestV1.node.ts | 47 +++++++ .../HttpRequest/V2/HttpRequestV2.node.ts | 63 +++++++++ .../HttpRequest/V3/HttpRequestV3.node.ts | 68 ++++++++- packages/workflow/src/index.ts | 1 + packages/workflow/src/utils.ts | 40 ++++++ packages/workflow/test/utils.test.ts | 131 ++++++++++++++++++ 10 files changed, 585 insertions(+), 16 deletions(-) diff --git a/cypress/e2e/45-ai-assistant.cy.ts b/cypress/e2e/45-ai-assistant.cy.ts index dab0d36147..d5d7649b4f 100644 --- a/cypress/e2e/45-ai-assistant.cy.ts +++ b/cypress/e2e/45-ai-assistant.cy.ts @@ -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'); }); }); diff --git a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts index fde57b632c..6048f8b914 100644 --- a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts +++ b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts @@ -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; diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index 74f9239478..59ca4fd468 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -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, diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index b601945693..e2dbe9cbaa 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -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, + ): 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]; + } } diff --git a/packages/nodes-base/nodes/HttpRequest/V1/HttpRequestV1.node.ts b/packages/nodes-base/nodes/HttpRequest/V1/HttpRequestV1.node.ts index 7d1618ab80..16d61fa8e5 100644 --- a/packages/nodes-base/nodes/HttpRequest/V1/HttpRequestV1.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V1/HttpRequestV1.node.ts @@ -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 && diff --git a/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts b/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts index fa2519c972..be47ea3f59 100644 --- a/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V2/HttpRequestV2.node.ts @@ -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 && diff --git a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts index a1eaa763d3..faafd5e9b8 100644 --- a/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts @@ -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; diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 67a58048bd..e41b2f3d72 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -44,6 +44,7 @@ export { randomString, isSafeObjectProperty, setSafeObjectProperty, + isDomainAllowed, } from './utils'; export { isINodeProperties, diff --git a/packages/workflow/src/utils.ts b/packages/workflow/src/utils.ts index 03cb15ecdb..e9541adec6 100644 --- a/packages/workflow/src/utils.ts +++ b/packages/workflow/src/utils.ts @@ -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; + } +} diff --git a/packages/workflow/test/utils.test.ts b/packages/workflow/test/utils.test.ts index 22ff18eb03..1ee15f4851 100644 --- a/packages/workflow/test/utils.test.ts +++ b/packages/workflow/test/utils.test.ts @@ -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); + }); + }); +});