diff --git a/packages/@n8n/config/src/configs/__tests__/security.config.test.ts b/packages/@n8n/config/src/configs/__tests__/security.config.test.ts index d816c4e453a..b877f3d8288 100644 --- a/packages/@n8n/config/src/configs/__tests__/security.config.test.ts +++ b/packages/@n8n/config/src/configs/__tests__/security.config.test.ts @@ -14,6 +14,18 @@ describe('SecurityConfig', () => { process.env = originalEnv; }); + describe('azureStorageCustomEndpoints', () => { + test('defaults to false', () => { + process.env = {}; + expect(Container.get(SecurityConfig).azureStorageCustomEndpoints).toBe(false); + }); + + test('is enabled by N8N_AZURE_STORAGE_CUSTOM_ENDPOINTS_ENABLED=true', () => { + process.env = { N8N_AZURE_STORAGE_CUSTOM_ENDPOINTS_ENABLED: 'true' }; + expect(Container.get(SecurityConfig).azureStorageCustomEndpoints).toBe(true); + }); + }); + describe('awsSystemCredentialsSdkSources', () => { test('defaults to "all"', () => { process.env = {}; diff --git a/packages/@n8n/config/src/configs/security.config.ts b/packages/@n8n/config/src/configs/security.config.ts index 97bc0d85632..e24831f5086 100644 --- a/packages/@n8n/config/src/configs/security.config.ts +++ b/packages/@n8n/config/src/configs/security.config.ts @@ -161,6 +161,14 @@ export class SecurityConfig { @Env('N8N_AWS_SYSTEM_CREDENTIALS_SDK_SOURCES', awsSystemCredentialsSdkSourcesSchema) awsSystemCredentialsSdkSources: string = 'all'; + /** + * Whether Azure Storage Shared Key credentials can target a custom endpoint, such as a private + * endpoint or a custom domain. Off by default. The Azure sovereign clouds are always available + * and do not need this setting. + */ + @Env('N8N_AZURE_STORAGE_CUSTOM_ENDPOINTS_ENABLED') + azureStorageCustomEndpoints: boolean = false; + /** * Whether to enable hooks (like pre-commit hooks) for the Git node. */ diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 53fe2c0af47..f0f5911d415 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -542,6 +542,7 @@ describe('GlobalConfig', () => { disableBareRepos: true, awsSystemCredentialsAccess: false, awsSystemCredentialsSdkSources: 'all', + azureStorageCustomEndpoints: false, enableGitNodeHooks: false, enableGitNodeAllConfigKeys: false, postMessageAllowedOrigins: '', diff --git a/packages/nodes-base/credentials/AzureStorageSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/AzureStorageSharedKeyApi.credentials.ts index efdda210732..876c53c8425 100644 --- a/packages/nodes-base/credentials/AzureStorageSharedKeyApi.credentials.ts +++ b/packages/nodes-base/credentials/AzureStorageSharedKeyApi.credentials.ts @@ -1,3 +1,5 @@ +import { SecurityConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import type { ICredentialDataDecryptedObject, ICredentialTestRequest, @@ -5,6 +7,7 @@ import type { IHttpRequestOptions, INodeProperties, } from 'n8n-workflow'; +import { UserError } from 'n8n-workflow'; import { createHmac } from 'node:crypto'; import { @@ -14,6 +17,44 @@ import { XMsVersion, } from '../nodes/Microsoft/Storage/GenericFunctions'; +const AZURE_CLOUD_SUFFIXES = [ + 'blob.core.windows.net', + 'blob.core.usgovcloudapi.net', + 'blob.core.chinacloudapi.cn', +]; + +function assertEndpointAllowed(credentials: ICredentialDataDecryptedObject): void { + if (!/^[a-z0-9]+$/i.test(String(credentials.account ?? ''))) { + throw new UserError('The account name can contain only letters and numbers.'); + } + + const environment = credentials.environment ?? AZURE_CLOUD_SUFFIXES[0]; + if (environment !== 'custom') { + if (!AZURE_CLOUD_SUFFIXES.includes(String(environment))) { + throw new UserError('Select an Azure cloud from the list.'); + } + return; + } + + if (!Container.get(SecurityConfig).azureStorageCustomEndpoints) { + throw new UserError( + 'Custom Azure Storage endpoints are disabled on this instance, contact your administrator.', + ); + } + if (!credentials.customEndpoint) { + throw new UserError('Endpoint is required when Azure Cloud is set to Custom.'); + } + let endpoint: URL | undefined; + try { + endpoint = new URL(String(credentials.customEndpoint)); + } catch {} + if (!endpoint || endpoint.protocol !== 'https:' || endpoint.href !== `${endpoint.origin}/`) { + throw new UserError( + 'Endpoint must be an https:// URL with only a hostname and an optional port, for example https://myaccount.privatelink.blob.core.windows.net', + ); + } +} + export class AzureStorageSharedKeyApi implements ICredentialType { name = 'azureStorageSharedKeyApi'; @@ -39,11 +80,56 @@ export class AzureStorageSharedKeyApi implements ICredentialType { }, default: '', }, + { + displayName: 'Azure Cloud', + name: 'environment', + type: 'options', + options: [ + { + name: 'Azure Public Cloud', + value: 'blob.core.windows.net', + description: 'Uses blob.core.windows.net', + }, + { + name: 'Azure US Government', + value: 'blob.core.usgovcloudapi.net', + description: 'Uses blob.core.usgovcloudapi.net', + }, + { + name: 'Azure China', + value: 'blob.core.chinacloudapi.cn', + description: 'Uses blob.core.chinacloudapi.cn', + }, + { + name: 'Custom', + value: 'custom', + description: + 'A private endpoint or a custom domain. An administrator must enable custom endpoints on this n8n instance.', + }, + ], + default: 'blob.core.windows.net', + }, + { + displayName: 'Endpoint', + name: 'customEndpoint', + type: 'string', + default: '', + required: true, + placeholder: 'https://myaccount.privatelink.blob.core.windows.net', + displayOptions: { + show: { + environment: ['custom'], + }, + }, + description: + 'The https:// URL of the storage endpoint. The account name must be in the hostname. An administrator must set N8N_AZURE_STORAGE_CUSTOM_ENDPOINTS_ENABLED=true on this n8n instance. Endpoints with the account name in the path, such as Azurite, do not work.', + }, { displayName: 'Base URL', name: 'baseUrl', type: 'hidden', - default: '=https://{{ $self["account"] }}.blob.core.windows.net', + default: + '={{ $self["environment"] === "custom" ? $self["customEndpoint"] : "https://" + $self["account"] + "." + $self["environment"] }}', }, ]; @@ -51,6 +137,8 @@ export class AzureStorageSharedKeyApi implements ICredentialType { credentials: ICredentialDataDecryptedObject, requestOptions: IHttpRequestOptions, ): Promise { + assertEndpointAllowed(credentials); + if (requestOptions.qs) { for (const [key, value] of Object.entries(requestOptions.qs)) { if (value === undefined) { diff --git a/packages/nodes-base/credentials/test/AzureStorageSharedKeyApi.credentials.test.ts b/packages/nodes-base/credentials/test/AzureStorageSharedKeyApi.credentials.test.ts new file mode 100644 index 00000000000..0e3f9d4c2f9 --- /dev/null +++ b/packages/nodes-base/credentials/test/AzureStorageSharedKeyApi.credentials.test.ts @@ -0,0 +1,259 @@ +import type { + ICredentialDataDecryptedObject, + IHttpRequestOptions, + INodeParameters, + INodeType, + INodeTypes, +} from 'n8n-workflow'; +import { NodeHelpers, UserError, Workflow } from 'n8n-workflow'; + +import { HeaderConstants } from '../../nodes/Microsoft/Storage/GenericFunctions'; +import { AzureStorageSharedKeyApi } from '../AzureStorageSharedKeyApi.credentials'; + +const { mockContainer, MockSecurityConfig } = vi.hoisted(() => { + class MockSecurityConfig { + azureStorageCustomEndpoints = false; + } + return { mockContainer: { get: vi.fn() }, MockSecurityConfig }; +}); + +vi.mock('@n8n/di', () => ({ Container: mockContainer })); +vi.mock('@n8n/config', () => ({ SecurityConfig: MockSecurityConfig })); + +describe('AzureStorageSharedKeyApi Credential', () => { + const credential = new AzureStorageSharedKeyApi(); + const property = (name: string) => credential.properties.find((p) => p.name === name); + const securityConfig = new MockSecurityConfig(); + const credentials: ICredentialDataDecryptedObject = { + account: 'myaccount', + key: 'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==', + }; + const privateEndpoint = 'https://myaccount.privatelink.blob.core.windows.net'; + const publicCloudSignature = 'SharedKey myaccount:LTfRKI8awvSOEoMJ6moAHQKF9Rt38hRdut6PsXHhnUI='; + const listBlobsRequest = (baseURL: string): IHttpRequestOptions => ({ + method: 'GET', + baseURL, + url: '/mycontainer', + qs: { restype: 'container', comp: 'list' }, + headers: { 'x-ms-date': 'Wed, 01 Jan 2025 00:00:00 GMT', 'x-ms-version': '2021-12-02' }, + }); + + const nodeTypes: INodeTypes = { + getByName: () => ({ description: { properties: [] } }) as unknown as INodeType, + getByNameAndVersion: () => ({ description: { properties: [] } }) as unknown as INodeType, + getKnownTypes: () => ({}), + }; + const resolveDefault = (expression: unknown, selfData: ICredentialDataDecryptedObject) => { + const workflow = new Workflow({ + id: '1', + nodes: [ + { + name: 'Credential', + type: 'n8n-nodes-base.noOp', + typeVersion: 1, + id: 'credential-1', + position: [0, 0], + parameters: {}, + }, + ], + connections: {}, + active: false, + nodeTypes, + }); + return workflow.expression.getComplexParameterValue( + workflow.getNode('Credential')!, + expression as string, + 'internal', + {}, + undefined, + undefined, + selfData, + ); + }; + + beforeEach(() => { + securityConfig.azureStorageCustomEndpoints = false; + mockContainer.get.mockReset(); + mockContainer.get.mockReturnValue(securityConfig); + }); + + describe('properties', () => { + it('should offer the Azure clouds and a custom endpoint, defaulting to the public cloud', () => { + const environment = property('environment'); + + expect(environment?.type).toBe('options'); + expect(environment?.default).toBe('blob.core.windows.net'); + expect(environment?.options?.map((option) => 'value' in option && option.value)).toEqual([ + 'blob.core.windows.net', + 'blob.core.usgovcloudapi.net', + 'blob.core.chinacloudapi.cn', + 'custom', + ]); + }); + + it('should ask for the endpoint only for a custom cloud', () => { + const endpoint = property('customEndpoint'); + + expect(endpoint?.type).toBe('string'); + expect(endpoint?.required).toBe(true); + expect(endpoint?.displayOptions).toEqual({ show: { environment: ['custom'] } }); + }); + + it.each([ + ['blob.core.windows.net', '', 'https://myaccount.blob.core.windows.net'], + ['blob.core.usgovcloudapi.net', '', 'https://myaccount.blob.core.usgovcloudapi.net'], + ['blob.core.chinacloudapi.cn', '', 'https://myaccount.blob.core.chinacloudapi.cn'], + ['custom', privateEndpoint, privateEndpoint], + ])('should build the hidden base URL for %s', (environment, customEndpoint, expected) => { + const baseUrl = property('baseUrl'); + + expect(baseUrl?.type).toBe('hidden'); + expect( + resolveDefault(baseUrl?.default, { account: 'myaccount', environment, customEndpoint }), + ).toBe(expected); + }); + + it('should resolve a credential saved before the cloud selection existed to the public cloud', () => { + const stored = { account: 'myaccount', key: 'secret' }; + const withDefaults = NodeHelpers.getNodeParameters( + credential.properties, + stored as INodeParameters, + true, + false, + null, + null, + ) as ICredentialDataDecryptedObject; + + expect(withDefaults.environment).toBe('blob.core.windows.net'); + expect(resolveDefault(withDefaults.baseUrl, withDefaults)).toBe( + 'https://myaccount.blob.core.windows.net', + ); + }); + + it('should test the credential against the configured base URL', () => { + expect(credential.test.request.baseURL).toBe('={{$credentials.baseUrl}}'); + }); + }); + + describe('authenticate', () => { + const custom = (customEndpoint: string): ICredentialDataDecryptedObject => ({ + ...credentials, + environment: 'custom', + customEndpoint, + }); + + it('should sign with the account name and path, independent of the host', async () => { + const publicCloud = await credential.authenticate( + credentials, + listBlobsRequest('https://myaccount.blob.core.windows.net'), + ); + const sovereignCloud = await credential.authenticate( + { ...credentials, environment: 'blob.core.chinacloudapi.cn' }, + listBlobsRequest('https://myaccount.blob.core.chinacloudapi.cn'), + ); + + expect(publicCloud.headers?.[HeaderConstants.AUTHORIZATION]).toBe(publicCloudSignature); + expect(sovereignCloud.headers?.[HeaderConstants.AUTHORIZATION]).toBe(publicCloudSignature); + }); + + it('should not consult the instance setting for an Azure cloud', async () => { + mockContainer.get.mockImplementation(() => { + throw new Error('instance setting read'); + }); + + const result = await credential.authenticate( + { ...credentials, environment: 'blob.core.usgovcloudapi.net' }, + listBlobsRequest('https://myaccount.blob.core.usgovcloudapi.net'), + ); + + expect(result.headers?.[HeaderConstants.AUTHORIZATION]).toBe(publicCloudSignature); + }); + + it.each(['my-account', 'myaccount.example.com', 'myaccount/x', ''])( + 'should reject the account name %j', + async (account) => { + const request = credential.authenticate( + { ...credentials, account }, + listBlobsRequest('https://myaccount.blob.core.windows.net'), + ); + + await expect(request).rejects.toThrow(UserError); + await expect(request).rejects.toThrow( + 'The account name can contain only letters and numbers.', + ); + }, + ); + + it('should reject a cloud that is not in the list', async () => { + const request = credential.authenticate( + { ...credentials, environment: 'blob.example.com' }, + listBlobsRequest('https://myaccount.blob.example.com'), + ); + + await expect(request).rejects.toThrow(UserError); + await expect(request).rejects.toThrow('Select an Azure cloud from the list.'); + }); + + it('should refuse a custom endpoint when the instance does not allow it', async () => { + const request = credential.authenticate( + custom(privateEndpoint), + listBlobsRequest(privateEndpoint), + ); + + await expect(request).rejects.toThrow(UserError); + await expect(request).rejects.toThrow( + 'Custom Azure Storage endpoints are disabled on this instance, contact your administrator.', + ); + }); + + it.each([privateEndpoint, `${privateEndpoint}/`, 'https://myaccount.example.com:10000'])( + 'should sign a request to the custom endpoint %j when the instance allows it', + async (customEndpoint) => { + securityConfig.azureStorageCustomEndpoints = true; + + const result = await credential.authenticate( + custom(customEndpoint), + listBlobsRequest(customEndpoint), + ); + + expect(result.headers?.[HeaderConstants.AUTHORIZATION]).toBe(publicCloudSignature); + }, + ); + + it('should require an endpoint for a custom cloud', async () => { + securityConfig.azureStorageCustomEndpoints = true; + + const request = credential.authenticate( + custom(''), + listBlobsRequest('https://myaccount.blob.core.windows.net'), + ); + + await expect(request).rejects.toThrow(UserError); + await expect(request).rejects.toThrow( + 'Endpoint is required when Azure Cloud is set to Custom.', + ); + }); + + it.each([ + 'myaccount.privatelink.blob.core.windows.net', + 'ftp://myaccount.example.com', + 'http://myaccount.example.com', + 'https://user:pass@myaccount.example.com', + 'https://myaccount.example.com/devstoreaccount1', + 'https://myaccount.example.com/?x=1', + 'https://myaccount.example.com/#fragment', + ])('should reject the endpoint %j', async (customEndpoint) => { + securityConfig.azureStorageCustomEndpoints = true; + + const request = credential.authenticate( + custom(customEndpoint), + listBlobsRequest('https://myaccount.blob.core.windows.net'), + ); + + await expect(request).rejects.toThrow(UserError); + await expect(request).rejects.toThrow( + 'Endpoint must be an https:// URL with only a hostname and an optional port', + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Storage/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/Storage/GenericFunctions.ts index 072f4de1e9f..00d8f1f94c8 100644 --- a/packages/nodes-base/nodes/Microsoft/Storage/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/Storage/GenericFunctions.ts @@ -97,7 +97,7 @@ export async function azureStorageApiRequest( const options: IHttpRequestOptions = { method, - url: url ?? `${credentials.baseUrl}${endpoint}`, + url: url ?? `${credentials.baseUrl.replace(/\/+$/, '')}${endpoint}`, headers, body, qs, diff --git a/packages/nodes-base/nodes/Microsoft/Storage/test/listSearch/listSearch.test.ts b/packages/nodes-base/nodes/Microsoft/Storage/test/listSearch/listSearch.test.ts index d9f6985e556..d43ae153f58 100644 --- a/packages/nodes-base/nodes/Microsoft/Storage/test/listSearch/listSearch.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Storage/test/listSearch/listSearch.test.ts @@ -6,11 +6,11 @@ import { credentials } from '../credentials'; describe('Azure Storage Node', () => { const { baseUrl } = credentials.azureStorageOAuth2Api; + const blobListResponse = `1myblob1Wed, 22 Jan 2025 18:53:15 GMTWed, 22 Jan 2025 18:53:15 GMT0x1F8268B228AA73037application/jsonaWQGHD8kGQd5ZtEN/S1/aw==BlockBlobunlockedavailabletrueHottrueWed, 22 Jan 2025 18:53:15 GMTmyblob2`; describe('List search', () => { it('should list search blobs', async () => { - const mockResponse = `1myblob1Wed, 22 Jan 2025 18:53:15 GMTWed, 22 Jan 2025 18:53:15 GMT0x1F8268B228AA73037application/jsonaWQGHD8kGQd5ZtEN/S1/aw==BlockBlobunlockedavailabletrueHottrueWed, 22 Jan 2025 18:53:15 GMTmyblob2`; - const mockRequestWithAuthentication = vi.fn().mockReturnValue(mockResponse); + const mockRequestWithAuthentication = vi.fn().mockReturnValue(blobListResponse); const mockGetNodeParameter = vi.fn((parameterName, _fallbackValue, _options) => { if (parameterName === 'authentication') { return 'sharedKey'; @@ -111,5 +111,30 @@ describe('Azure Storage Node', () => { paginationToken: 'mycontainer2', }); }); + it('should build the request URL without a double slash when the base URL ends with one', async () => { + const mockRequestWithAuthentication = vi.fn().mockReturnValue(blobListResponse); + const mockContext = { + getCredentials: vi.fn(async () => ({ + ...credentials.azureStorageSharedKeyApi, + baseUrl: 'https://myaccount.blob.core.chinacloudapi.cn/', + })), + getNodeParameter: vi.fn((parameterName: string) => + parameterName === 'authentication' ? 'sharedKey' : { value: 'mycontainer' }, + ), + helpers: { + requestWithAuthentication: mockRequestWithAuthentication, + }, + } as unknown as ILoadOptionsFunctions; + const node = new AzureStorage(); + + await node.methods.listSearch.getBlobs.call(mockContext); + + expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + 'azureStorageSharedKeyApi', + expect.objectContaining({ + url: 'https://myaccount.blob.core.chinacloudapi.cn/mycontainer', + }), + ); + }); }); });