From fea0a62f8ec3e4b8c56cd78bbfd8c1a8924b22b2 Mon Sep 17 00:00:00 2001 From: yehorkardash Date: Thu, 11 Sep 2025 08:55:52 +0000 Subject: [PATCH] fix(core): Add support for .cn Amazon regions (#19363) --- .../nodes-base/credentials/Aws.credentials.ts | 34 ++++-- .../credentials/test/Aws.credentials.test.ts | 103 +++++++++++++++++- 2 files changed, 129 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/credentials/Aws.credentials.ts b/packages/nodes-base/credentials/Aws.credentials.ts index 7b977624e4..8333cea3d8 100644 --- a/packages/nodes-base/credentials/Aws.credentials.ts +++ b/packages/nodes-base/credentials/Aws.credentials.ts @@ -11,7 +11,17 @@ import type { } from 'n8n-workflow'; import { isObjectEmpty } from 'n8n-workflow'; -export const regions = [ +type RegionData = { + name: string; + displayName: string; + location: string; + domain?: string; +}; + +const chinaDomain = 'amazonaws.com.cn'; +const globalDomain = 'amazonaws.com'; + +export const regions: RegionData[] = [ { name: 'af-south-1', displayName: 'Africa', @@ -91,11 +101,13 @@ export const regions = [ name: 'cn-north-1', displayName: 'China', location: 'Beijing', + domain: chinaDomain, }, { name: 'cn-northwest-1', displayName: 'China', location: 'Ningxia', + domain: chinaDomain, }, { name: 'eu-central-1', @@ -211,12 +223,18 @@ export type AwsCredentialsType = { ssmEndpoint?: string; }; +function getAwsDomain(region: AWSRegion): string { + return regions.find((r) => r.name === region)?.domain ?? globalDomain; +} + // Some AWS services are global and don't have a region // https://docs.aws.amazon.com/general/latest/gr/rande.html#global-endpoints // Example: iam.amazonaws.com (global), s3.us-east-1.amazonaws.com (regional) function parseAwsUrl(url: URL): { region: AWSRegion | null; service: string } { - const [service, region] = url.hostname.replace('amazonaws.com', '').split('.'); - return { service, region: region as AWSRegion }; + const hostname = url.hostname; + // Handle both .amazonaws.com and .amazonaws.com.cn domains + const [service, region] = hostname.replace(/\.amazonaws\.com.*$/, '').split('.'); + return { service, region }; } export class Aws implements ICredentialType { @@ -436,10 +454,11 @@ export class Aws implements ICredentialType { endpointString = credentials.sesEndpoint; } else if (service === 'rekognition' && credentials.rekognitionEndpoint) { endpointString = credentials.rekognitionEndpoint; - } else if (service) { - endpointString = `https://${service}.${region}.amazonaws.com`; } else if (service === 'ssm' && credentials.ssmEndpoint) { endpointString = credentials.ssmEndpoint; + } else if (service) { + const domain = getAwsDomain(region); + endpointString = `https://${service}.${region}.${domain}`; } endpoint = new URL(endpointString!.replace('{region}', region) + path); } else { @@ -486,7 +505,6 @@ export class Aws implements ICredentialType { bodyContent = params.toString(); contentTypeHeader = 'application/x-www-form-urlencoded'; } - const signOpts = { ...requestOptions, headers: { @@ -526,7 +544,9 @@ export class Aws implements ICredentialType { test: ICredentialTestRequest = { request: { - baseURL: '=https://sts.{{$credentials.region}}.amazonaws.com', + baseURL: + // eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string + '={{$credentials.region.startsWith("cn-") ? `https://sts.${$credentials.region}.amazonaws.com.cn` : `https://sts.${$credentials.region}.amazonaws.com`}}', url: '?Action=GetCallerIdentity&Version=2011-06-15', method: 'POST', }, diff --git a/packages/nodes-base/credentials/test/Aws.credentials.test.ts b/packages/nodes-base/credentials/test/Aws.credentials.test.ts index 1e936da269..ee6ef32f31 100644 --- a/packages/nodes-base/credentials/test/Aws.credentials.test.ts +++ b/packages/nodes-base/credentials/test/Aws.credentials.test.ts @@ -25,7 +25,10 @@ describe('Aws Credential', () => { expect(aws.documentationUrl).toBe('aws'); expect(aws.icon).toEqual({ light: 'file:icons/AWS.svg', dark: 'file:icons/AWS.dark.svg' }); expect(aws.properties.length).toBeGreaterThan(0); - expect(aws.test.request.baseURL).toBe('=https://sts.{{$credentials.region}}.amazonaws.com'); + expect(aws.test.request.baseURL).toBe( + // eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string + '={{$credentials.region.startsWith("cn-") ? `https://sts.${$credentials.region}.amazonaws.com.cn` : `https://sts.${$credentials.region}.amazonaws.com`}}', + ); expect(aws.test.request.url).toBe('?Action=GetCallerIdentity&Version=2011-06-15'); expect(aws.test.request.method).toBe('POST'); }); @@ -178,5 +181,103 @@ describe('Aws Credential', () => { expect(result.method).toBe('POST'); expect(result.url).toBe('https://iam.amazonaws.com/'); }); + + describe('China regions', () => { + const chinaCredentials: AwsCredentialsType = { + region: 'cn-north-1', + accessKeyId: 'hakuna', + secretAccessKey: 'matata', + customEndpoints: false, + temporaryCredentials: false, + }; + + it('should use amazonaws.com.cn domain for cn-north-1 region', async () => { + const result = await aws.authenticate(chinaCredentials, { + ...requestOptions, + url: '', + baseURL: '', + qs: { service: 's3' }, + }); + + expect(mockSign).toHaveBeenCalledWith( + expect.objectContaining({ + host: 's3.cn-north-1.amazonaws.com.cn', + region: 'cn-north-1', + }), + { + accessKeyId: 'hakuna', + secretAccessKey: 'matata', + sessionToken: undefined, + }, + ); + expect(result.url).toBe('https://s3.cn-north-1.amazonaws.com.cn/'); + }); + + it('should handle custom endpoints for China regions', async () => { + const customEndpoint = 'https://custom.china.endpoint.com.cn'; + const result = await aws.authenticate( + { ...chinaCredentials, customEndpoints: true, s3Endpoint: customEndpoint }, + { ...requestOptions, url: '', baseURL: '', qs: { service: 's3' } }, + ); + + expect(mockSign).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'custom.china.endpoint.com.cn', + }), + { + accessKeyId: 'hakuna', + secretAccessKey: 'matata', + sessionToken: undefined, + }, + ); + expect(result.url).toBe(`${customEndpoint}/`); + }); + + it('should parse China region URLs correctly', async () => { + const result = await aws.authenticate(chinaCredentials, { + ...requestOptions, + url: 'https://s3.cn-north-1.amazonaws.com.cn/bucket/key', + baseURL: '', + }); + + expect(mockSign).toHaveBeenCalledWith( + expect.objectContaining({ + host: 's3.cn-north-1.amazonaws.com.cn', + region: 'cn-north-1', + path: '/bucket/key', + }), + { + accessKeyId: 'hakuna', + secretAccessKey: 'matata', + sessionToken: undefined, + }, + ); + expect(result.url).toBe('https://s3.cn-north-1.amazonaws.com.cn/bucket/key'); + }); + }); + + describe('Regular regions (non-China)', () => { + it('should use amazonaws.com domain for regular regions', async () => { + const result = await aws.authenticate(credentials, { + ...requestOptions, + url: '', + baseURL: '', + qs: { service: 's3' }, + }); + + expect(mockSign).toHaveBeenCalledWith( + expect.objectContaining({ + host: 's3.eu-central-1.amazonaws.com', + region: 'eu-central-1', + }), + { + accessKeyId: 'hakuna', + secretAccessKey: 'matata', + sessionToken: undefined, + }, + ); + expect(result.url).toBe('https://s3.eu-central-1.amazonaws.com/'); + }); + }); }); });