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