mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
fix(HTTP Request Node): Sign non-standard AWS endpoint hosts with the correct region and service (#34081)
Co-authored-by: Thanasis Gkliatis <thanasis.gkliatis@n8n.io> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Thanasis G <96360514+gthanasis@users.noreply.github.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { UserError, type IHttpRequestOptions } from 'n8n-workflow';
|
||||
|
||||
import type { AWSRegion } from './regions';
|
||||
import { regions, type AWSRegion } from './regions';
|
||||
import * as systemCredentialsUtils from './system-credentials-utils';
|
||||
import type { AwsAssumeRoleCredentialsType, AwsIamCredentialsType } from './types';
|
||||
import {
|
||||
assertSupportedAwsRegion,
|
||||
assumeRole,
|
||||
AWS_REGION_SHAPE_PATTERN,
|
||||
awsGetSignInOptionsAndUpdateRequest,
|
||||
parseAwsUrl,
|
||||
validateBedrockEndpointOverride,
|
||||
@@ -645,6 +646,11 @@ describe('parseAwsUrl', () => {
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'sqs', region: 'cn-north-1' });
|
||||
});
|
||||
|
||||
it('keeps the vpce branch positional even when the service label is region-like', () => {
|
||||
const url = new URL('https://vpce-0abc123.us-east-1.us-west-2.vpce.amazonaws.com/');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'us-east-1', region: 'us-west-2' });
|
||||
});
|
||||
|
||||
it('parses a standard public hostname (regression)', () => {
|
||||
const url = new URL('https://lambda.us-east-1.amazonaws.com/');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'lambda', region: 'us-east-1' });
|
||||
@@ -659,6 +665,190 @@ describe('parseAwsUrl', () => {
|
||||
const url = new URL('https://iam.amazonaws.com/');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'iam', region: null });
|
||||
});
|
||||
|
||||
it('parses a dual-stack hostname', () => {
|
||||
const url = new URL('https://s3.dualstack.us-east-1.amazonaws.com/bucket/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: 'us-east-1' });
|
||||
});
|
||||
|
||||
it('parses a dual-stack hostname on the China domain', () => {
|
||||
const url = new URL('https://s3.dualstack.cn-north-1.amazonaws.com.cn/bucket/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: 'cn-north-1' });
|
||||
});
|
||||
|
||||
it('parses a FIPS hostname, keeping the -fips service label verbatim (normalization is signing-side)', () => {
|
||||
const url = new URL('https://s3-fips.us-east-1.amazonaws.com/');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3-fips', region: 'us-east-1' });
|
||||
});
|
||||
|
||||
it('parses a combined FIPS + dual-stack hostname', () => {
|
||||
const url = new URL('https://s3-fips.dualstack.us-east-1.amazonaws.com/');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3-fips', region: 'us-east-1' });
|
||||
});
|
||||
|
||||
it('prefers the rightmost region-shaped label when a bucket label is itself named like a region', () => {
|
||||
const url = new URL('https://us-east-2.s3.us-east-1.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: 'us-east-1' });
|
||||
});
|
||||
|
||||
it('parses a legacy region-first SQS hostname', () => {
|
||||
const url = new URL('https://us-east-2.queue.amazonaws.com/123456789012/my-queue');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'queue', region: 'us-east-2' });
|
||||
});
|
||||
|
||||
it('parses a region-middle OpenSearch domain hostname (service right of the region)', () => {
|
||||
const url = new URL('https://search-mydomain-abc123.us-east-1.es.amazonaws.com/_search');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'es', region: 'us-east-1' });
|
||||
});
|
||||
|
||||
it('resolves the service left of the region for a bucket-qualified S3 interface endpoint', () => {
|
||||
// Outside VPCE_HOSTNAME_PATTERN by design (extra bucket label before the vpce id);
|
||||
// the trailing `vpce` label must not be mistaken for a region-middle service.
|
||||
const url = new URL('https://mybucket.vpce-0abc123.s3.us-east-1.vpce.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: 'us-east-1' });
|
||||
});
|
||||
|
||||
it('parses an S3 access-point hostname', () => {
|
||||
const url = new URL('https://myap-123456789012.s3-accesspoint.us-west-2.amazonaws.com/');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3-accesspoint', region: 'us-west-2' });
|
||||
});
|
||||
|
||||
it('parses an API Gateway execute-api hostname', () => {
|
||||
const url = new URL('https://myapi123.execute-api.us-west-2.amazonaws.com/prod/resource');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'execute-api', region: 'us-west-2' });
|
||||
});
|
||||
|
||||
it('parses a virtual-hosted S3 bucket hostname', () => {
|
||||
const url = new URL('https://bucket.s3.us-east-1.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: 'us-east-1' });
|
||||
});
|
||||
|
||||
it('parses a legacy fips-prefixed hostname', () => {
|
||||
const url = new URL('https://fips.sqs.us-east-1.amazonaws.com/123456789012/my-queue');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'sqs', region: 'us-east-1' });
|
||||
});
|
||||
|
||||
it('resolves the service label even when the bucket is named exactly like the region', () => {
|
||||
const url = new URL('https://us-east-1.s3.us-east-1.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: 'us-east-1' });
|
||||
});
|
||||
|
||||
it('skips the dualstack qualifier when resolving the service at depth', () => {
|
||||
const url = new URL('https://bucket.s3.dualstack.us-east-1.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: 'us-east-1' });
|
||||
});
|
||||
|
||||
it('keeps dualstack as the service when it is the first label (no qualifier skip below index 0)', () => {
|
||||
const url = new URL('https://dualstack.us-east-1.amazonaws.com/');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'dualstack', region: 'us-east-1' });
|
||||
});
|
||||
|
||||
it('does not let a supported bucket label shadow an unsupported region label in the region slot', () => {
|
||||
const url = new URL('https://us-east-1.s3.eu-central-9.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: 'eu-central-9' });
|
||||
});
|
||||
|
||||
it('surfaces a mistyped region label via the shape fallback', () => {
|
||||
const url = new URL('https://lambda.us-esat-1.amazonaws.com/');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'lambda', region: 'us-esat-1' });
|
||||
});
|
||||
|
||||
it('surfaces a mistyped GovCloud-shaped region label (multi-word shape)', () => {
|
||||
const url = new URL('https://lambda.us-gov-wast-1.amazonaws.com/');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'lambda', region: 'us-gov-wast-1' });
|
||||
});
|
||||
|
||||
it('surfaces a region-shaped label for a not-yet-supported region', () => {
|
||||
const url = new URL('https://lambda.eu-central-9.amazonaws.com/');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'lambda', region: 'eu-central-9' });
|
||||
});
|
||||
|
||||
it('surfaces a region-shaped label with a four-letter partition prefix', () => {
|
||||
const url = new URL('https://lambda.eusc-de-east-1.amazonaws.com/');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'lambda', region: 'eusc-de-east-1' });
|
||||
});
|
||||
|
||||
it('returns a null region when the label is not region-shaped (missing-hyphen typo)', () => {
|
||||
const url = new URL('https://lambda.us-east1.amazonaws.com/');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'lambda', region: null });
|
||||
});
|
||||
|
||||
it('returns a null region for an AWS host with no region-shaped label', () => {
|
||||
const url = new URL('https://foo.bar.amazonaws.com/');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'foo', region: null });
|
||||
});
|
||||
|
||||
it('does not shape-match the region-less legacy s3-external-1 host', () => {
|
||||
const url = new URL('https://s3-external-1.amazonaws.com/bucket/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3-external-1', region: null });
|
||||
});
|
||||
|
||||
it('does not shape-match the region-less legacy SQS queue host', () => {
|
||||
const url = new URL('https://queue.amazonaws.com/123456789012/my-queue');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'queue', region: null });
|
||||
});
|
||||
|
||||
it('parses a legacy region-less virtual-hosted S3 hostname', () => {
|
||||
const url = new URL('https://mybucket.s3.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: null });
|
||||
});
|
||||
|
||||
it('parses a legacy region-less S3 transfer-acceleration hostname', () => {
|
||||
const url = new URL('https://mybucket.s3-accelerate.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3-accelerate', region: null });
|
||||
});
|
||||
|
||||
it('parses a legacy region-less S3 accelerate dual-stack hostname', () => {
|
||||
const url = new URL('https://mybucket.s3-accelerate.dualstack.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3-accelerate', region: null });
|
||||
});
|
||||
|
||||
it('treats a region-shaped bucket label in front of s3 as a bucket, not the region', () => {
|
||||
const url = new URL('https://my-bucket-1.s3.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: null });
|
||||
});
|
||||
|
||||
it('treats a bucket named exactly like a region on the legacy global S3 endpoint as a bucket', () => {
|
||||
const url = new URL('https://us-east-2.s3.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: null });
|
||||
});
|
||||
|
||||
it('treats a region-shaped bucket label in front of s3-accelerate as a bucket', () => {
|
||||
const url = new URL('https://my-bucket-1.s3-accelerate.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3-accelerate', region: null });
|
||||
});
|
||||
|
||||
it('parses a legacy dash-region virtual-hosted S3 hostname', () => {
|
||||
const url = new URL('https://mybucket.s3-us-west-2.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: 'us-west-2' });
|
||||
});
|
||||
|
||||
it('parses a legacy dash-region path-style S3 hostname', () => {
|
||||
const url = new URL('https://s3-eu-west-1.amazonaws.com/bucket/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: 'eu-west-1' });
|
||||
});
|
||||
|
||||
it('prefers the dash-region label over a region-shaped bucket label', () => {
|
||||
const url = new URL('https://my-logs-1.s3-us-west-2.amazonaws.com/key');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 's3', region: 'us-west-2' });
|
||||
});
|
||||
|
||||
it('returns a null region for a custom host without region-shaped labels', () => {
|
||||
const url = new URL('https://myapi.example.com/x');
|
||||
expect(parseAwsUrl(url)).toEqual({ service: 'myapi', region: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AWS_REGION_SHAPE_PATTERN', () => {
|
||||
it('matches every supported region name', () => {
|
||||
// Invariant: the pattern must match every supported region, since the scan in
|
||||
// parseAwsUrl only considers region-shaped labels. If AWS ships a region with
|
||||
// a new prefix shape, widen the pattern alongside regions.ts.
|
||||
const nonMatching = regions
|
||||
.filter((r) => !AWS_REGION_SHAPE_PATTERN.test(r.name))
|
||||
.map((r) => r.name);
|
||||
expect(nonMatching).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('awsGetSignInOptionsAndUpdateRequest', () => {
|
||||
@@ -976,6 +1166,350 @@ describe('awsGetSignInOptionsAndUpdateRequest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dual-stack and FIPS endpoints', () => {
|
||||
it('signs a dual-stack S3 host with the host-derived region and the s3 service', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{
|
||||
uri: 'https://s3.dualstack.us-west-2.amazonaws.com/bucket/key',
|
||||
headers: {},
|
||||
} as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.region).toBe('us-west-2');
|
||||
expect(signOpts.service).toBe('s3');
|
||||
});
|
||||
|
||||
it('signs a FIPS S3 host under the base s3 service name', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{
|
||||
uri: 'https://s3-fips.us-east-1.amazonaws.com/bucket/key',
|
||||
headers: {},
|
||||
} as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'eu-central-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('s3');
|
||||
expect(signOpts.region).toBe('us-east-1');
|
||||
});
|
||||
|
||||
it('strips the -fips suffix before the Bedrock signing-name mapping', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{
|
||||
uri: 'https://bedrock-runtime-fips.us-east-1.amazonaws.com/model/x/invoke',
|
||||
headers: {},
|
||||
} as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
// bedrock-runtime-fips → bedrock-runtime → bedrock; stripping after the
|
||||
// mapping instead would sign with the unknown name bedrock-runtime-fips.
|
||||
expect(signOpts.service).toBe('bedrock');
|
||||
});
|
||||
|
||||
it('strips the -fips suffix for services without a signing-name mapping', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{ uri: 'https://lambda-fips.us-east-1.amazonaws.com/', headers: {} } as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('lambda');
|
||||
});
|
||||
});
|
||||
|
||||
describe('endpoints with qualifier labels left of the service', () => {
|
||||
it('signs an API Gateway invoke URL with the execute-api service', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{
|
||||
uri: 'https://myapi123.execute-api.us-west-2.amazonaws.com/prod/x',
|
||||
headers: {},
|
||||
} as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('execute-api');
|
||||
expect(signOpts.region).toBe('us-west-2');
|
||||
});
|
||||
|
||||
it('signs an S3 access-point host under the s3 service name', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{
|
||||
uri: 'https://myap-123456789012.s3-accesspoint.us-west-2.amazonaws.com/object',
|
||||
headers: {},
|
||||
} as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('s3');
|
||||
expect(signOpts.region).toBe('us-west-2');
|
||||
});
|
||||
|
||||
it('signs an S3 Control host under the s3 service name', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{
|
||||
uri: 'https://123456789012.s3-control.us-east-1.amazonaws.com/',
|
||||
headers: {},
|
||||
} as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('s3');
|
||||
});
|
||||
|
||||
it('signs a legacy region-less virtual-hosted S3 host as s3 with the credential region', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{ uri: 'https://mybucket.s3.amazonaws.com/key', headers: {} } as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('s3');
|
||||
expect(signOpts.region).toBe('us-east-1');
|
||||
});
|
||||
|
||||
it('signs an S3 transfer-acceleration host under the s3 service name', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{ uri: 'https://mybucket.s3-accelerate.amazonaws.com/key', headers: {} } as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('s3');
|
||||
});
|
||||
|
||||
it('signs a region-less legacy SQS global host as sqs with the credential region', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{ uri: 'https://queue.amazonaws.com/123456789012/my-queue', headers: {} } as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('sqs');
|
||||
expect(signOpts.region).toBe('us-east-1');
|
||||
});
|
||||
|
||||
it('signs a region-middle OpenSearch domain host with the es service and its region', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{
|
||||
uri: 'https://search-mydomain-abc123.us-west-2.es.amazonaws.com/_search',
|
||||
headers: {},
|
||||
} as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('es');
|
||||
expect(signOpts.region).toBe('us-west-2');
|
||||
});
|
||||
|
||||
it('signs an S3 Control FIPS host under the s3 service name (strip composes with the family)', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{
|
||||
uri: 'https://123456789012.s3-control-fips.us-east-1.amazonaws.com/',
|
||||
headers: {},
|
||||
} as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('s3');
|
||||
});
|
||||
|
||||
it('signs a legacy region-first SQS host with the sqs service and its own region', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{
|
||||
uri: 'https://us-east-2.queue.amazonaws.com/123456789012/my-queue',
|
||||
headers: {},
|
||||
} as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('sqs');
|
||||
expect(signOpts.region).toBe('us-east-2');
|
||||
});
|
||||
|
||||
it('signs a caller-supplied qualified S3 access-point service under the s3 service name', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{ headers: {} } as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'myap-123456789012.s3-accesspoint',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('s3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('region labels that cannot be adopted on AWS endpoint hosts', () => {
|
||||
it('signs a region-shaped bucket on the legacy global S3 endpoint with the credential region', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{ uri: 'https://test-logs-3.s3.amazonaws.com/key', headers: {} } as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('s3');
|
||||
expect(signOpts.region).toBe('us-east-1');
|
||||
});
|
||||
|
||||
it('signs a legacy dash-region S3 host with the embedded region', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{ uri: 'https://mybucket.s3-us-west-2.amazonaws.com/key', headers: {} } as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.service).toBe('s3');
|
||||
expect(signOpts.region).toBe('us-west-2');
|
||||
});
|
||||
|
||||
it('throws for a mistyped region embedded in a legacy dash-region S3 label', () => {
|
||||
const call = () =>
|
||||
awsGetSignInOptionsAndUpdateRequest(
|
||||
{ uri: 'https://s3-us-esat-1.amazonaws.com/bucket/key', headers: {} } as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(call).toThrow(UserError);
|
||||
expect(call).toThrow('us-esat-1');
|
||||
});
|
||||
|
||||
it('throws for a mistyped region label', () => {
|
||||
const call = () =>
|
||||
awsGetSignInOptionsAndUpdateRequest(
|
||||
{ uri: 'https://lambda.us-esat-1.amazonaws.com/', headers: {} } as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(call).toThrow(UserError);
|
||||
expect(call).toThrow('us-esat-1');
|
||||
});
|
||||
|
||||
it('throws for a region-shaped label the region list does not include yet', () => {
|
||||
const call = () =>
|
||||
awsGetSignInOptionsAndUpdateRequest(
|
||||
{ uri: 'https://lambda.eu-central-9.amazonaws.com/', headers: {} } as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(call).toThrow(UserError);
|
||||
expect(call).toThrow('eu-central-9');
|
||||
});
|
||||
|
||||
it('throws for an unsupported region slot even when a bucket label is a supported region', () => {
|
||||
const call = () =>
|
||||
awsGetSignInOptionsAndUpdateRequest(
|
||||
{ uri: 'https://us-east-1.s3.eu-central-9.amazonaws.com/key', headers: {} } as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(call).toThrow(UserError);
|
||||
expect(call).toThrow('eu-central-9');
|
||||
});
|
||||
|
||||
it('throws for a not-yet-supported region with a four-letter partition prefix', () => {
|
||||
const call = () =>
|
||||
awsGetSignInOptionsAndUpdateRequest(
|
||||
{ uri: 'https://lambda.eusc-de-east-1.amazonaws.com/', headers: {} } as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(call).toThrow(UserError);
|
||||
expect(call).toThrow('eusc-de-east-1');
|
||||
});
|
||||
|
||||
it.each(['https://lambda.us-east1.amazonaws.com/', 'https://foo.bar.amazonaws.com/'])(
|
||||
'keeps the credential region without throwing when no label is region-shaped (%s)',
|
||||
(uri) => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{ uri, headers: {} } as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.region).toBe('us-east-1');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('custom (non-AWS) endpoint hosts', () => {
|
||||
it('keeps the credential region when the host label is not a region (uri branch)', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
@@ -1026,6 +1560,40 @@ describe('awsGetSignInOptionsAndUpdateRequest', () => {
|
||||
|
||||
expect(signOpts.region).toBe('us-west-2');
|
||||
});
|
||||
|
||||
it('adopts a recognized region label from a deep custom-host label position', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{
|
||||
uri: 'https://proxy.api.us-east-1.example.com/x',
|
||||
headers: {},
|
||||
} as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'eu-central-1',
|
||||
);
|
||||
|
||||
expect(signOpts.region).toBe('us-east-1');
|
||||
// The service rule applies to custom hosts too: the label left of the region.
|
||||
expect(signOpts.service).toBe('api');
|
||||
});
|
||||
|
||||
it('keeps the credential region when a custom host label is region-shaped but unsupported', () => {
|
||||
const { signOpts } = awsGetSignInOptionsAndUpdateRequest(
|
||||
{
|
||||
uri: 'https://sqs.us-fake-1.mycompany.dev/queue',
|
||||
headers: {},
|
||||
} as any,
|
||||
baseCredentials,
|
||||
'',
|
||||
'GET',
|
||||
'',
|
||||
'us-east-1',
|
||||
);
|
||||
|
||||
expect(signOpts.region).toBe('us-east-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -61,14 +61,19 @@ function shouldStringifyBody<T>(value: T, headers: IDataObject): boolean {
|
||||
* @returns The SigV4 signing service name
|
||||
*/
|
||||
function getAwsSigningService(service: string): string {
|
||||
// FIPS endpoints (`<service>-fips.<region>.amazonaws.com`) sign with the base
|
||||
// service name; e.g. `s3-fips` signed as-is fails with SignatureDoesNotMatch.
|
||||
const baseService = service.replace(/-fips$/, '');
|
||||
// Virtual-hosted-style S3 requests arrive as `<bucket>.s3` (the node builds the
|
||||
// endpoint `<bucket>.s3.<region>.amazonaws.com`). They all sign under the `s3`
|
||||
// signing name. aws4 derived this by inspecting the host; smithy does not, so we
|
||||
// signing name, as do S3 access-point (`s3-accesspoint`) and S3 Control
|
||||
// (`s3-control`) endpoints — bare or with a bucket/access-point qualifier
|
||||
// prefix. aws4 derived this by inspecting the host; smithy does not, so we
|
||||
// normalize it here.
|
||||
if (service === 's3' || service.endsWith('.s3')) {
|
||||
if (/(^|\.)s3(-accesspoint|-control|-accelerate)?$/.test(baseService)) {
|
||||
return 's3';
|
||||
}
|
||||
switch (service) {
|
||||
switch (baseService) {
|
||||
// Mirror AWS SDK Bedrock signing for HTTP Request node AWS credentials:
|
||||
// these endpoint families are signed with the `bedrock` service namespace.
|
||||
// https://docs.aws.amazon.com/bedrock/latest/APIReference/welcome.html#API_Reference_Endpoints
|
||||
@@ -79,8 +84,11 @@ function getAwsSigningService(service: string): string {
|
||||
case 'bedrock-data-automation':
|
||||
case 'bedrock-data-automation-runtime':
|
||||
return 'bedrock';
|
||||
// Legacy region-first SQS endpoints (`<region>.queue.amazonaws.com`).
|
||||
case 'queue':
|
||||
return 'sqs';
|
||||
default:
|
||||
return service;
|
||||
return baseService;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,13 +248,45 @@ export function validateBedrockEndpointOverride(override: string, region: AWSReg
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of an AWS region label: a 2-4 letter partition prefix (`us`, `eusc`),
|
||||
* one or more word components, and a numeric suffix (`us-east-1`,
|
||||
* `us-gov-west-1`, `eusc-de-east-1`). Deliberately shape-only: a mistyped or
|
||||
* not-yet-supported region must reach the caller's validation instead of
|
||||
* being silently dropped.
|
||||
*/
|
||||
export const AWS_REGION_SHAPE_PATTERN = /^[a-z]{2,4}(-[a-z]+)+-\d+$/;
|
||||
|
||||
/**
|
||||
* Legacy dash-region S3 endpoints (`[<bucket>.]s3-<region>.amazonaws.com`)
|
||||
* encode the region inside the s3 label. Returns that region, or null when
|
||||
* the label doesn't carry one (`s3-accelerate`, `s3-external-1`).
|
||||
*/
|
||||
function parseLegacyS3DashRegion(label: string): string | null {
|
||||
if (!label.startsWith('s3-')) return null;
|
||||
const rest = label.slice(3);
|
||||
return AWS_REGION_SHAPE_PATTERN.test(rest) ? rest : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an AWS service URL to extract the service name and region.
|
||||
* Some AWS services are global and don't have a region. Recognizes both
|
||||
* public endpoints (`<service>.<region>.amazonaws.com`) and PrivateLink
|
||||
* endpoints (`vpce-<id>.<service>.<region>.vpce.amazonaws.com`).
|
||||
* Some AWS services are global and don't have a region. PrivateLink
|
||||
* endpoints (`vpce-<id>.<service>.<region>.vpce.amazonaws.com`) return their
|
||||
* positional service and region labels verbatim.
|
||||
*
|
||||
* The returned region is not validated against the supported region list;
|
||||
* On all other hostnames (public AWS endpoints, including dual-stack and
|
||||
* FIPS variants, and custom hosts) the region is the rightmost region-shaped
|
||||
* label, or null when no label matches. The service is the label right of
|
||||
* the region when the region is second-to-last on an AWS host (region-middle
|
||||
* and legacy region-first shapes: `<domain>.<region>.es`, `<region>.queue`);
|
||||
* otherwise the label left of the region (skipping a `dualstack` qualifier)
|
||||
* when qualifier labels such as a bucket name or API id precede it;
|
||||
* otherwise a trailing legacy region-less S3 service label (`<bucket>.s3`,
|
||||
* `<bucket>.s3-accelerate[.dualstack]`); otherwise the first label. Legacy
|
||||
* S3 shapes are special-cased: a region-shaped first label in front of
|
||||
* `s3`/`s3-accelerate` is a bucket name (null region), and dash-region
|
||||
* labels (`[<bucket>.]s3-<region>`) yield service `s3` with the embedded
|
||||
* region. The region is not validated against the supported region list;
|
||||
* callers must check it (e.g. with {@link assertSupportedAwsRegion}) before
|
||||
* using it for signing.
|
||||
*
|
||||
@@ -263,8 +303,69 @@ export function parseAwsUrl(url: URL): { region: string | null; service: string
|
||||
return { service, region };
|
||||
}
|
||||
// Handle both .amazonaws.com and .amazonaws.com.cn domains
|
||||
const [service, region] = hostname.replace(/\.amazonaws\.com.*$/, '').split('.');
|
||||
return { service, region: region ?? null };
|
||||
const labels = hostname.replace(/\.amazonaws\.com.*$/, '').split('.');
|
||||
// The region is the rightmost region-shaped label: AWS puts the region closest to
|
||||
// the domain suffix, and supported-ness is the caller's decision — checking it here
|
||||
// would let a bucket/qualifier label that happens to be a known region shadow a
|
||||
// mistyped or not-yet-supported label in the real region slot.
|
||||
let regionIdx = -1;
|
||||
for (let i = labels.length - 1; i >= 0; i--) {
|
||||
if (AWS_REGION_SHAPE_PATTERN.test(labels[i])) {
|
||||
regionIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const region = regionIdx === -1 ? null : labels[regionIdx];
|
||||
let service = labels[0];
|
||||
if (
|
||||
regionIdx !== -1 &&
|
||||
regionIdx === labels.length - 2 &&
|
||||
isAwsEndpointHostname(hostname) &&
|
||||
labels[regionIdx + 1] !== 'vpce'
|
||||
) {
|
||||
const next = labels[regionIdx + 1];
|
||||
if (regionIdx === 0) {
|
||||
// S3 never had a region-first shape, so a shaped first label in front of
|
||||
// an S3 service label is a bucket name, not the region.
|
||||
if (next === 's3' || next === 's3-accelerate') {
|
||||
return { service: next, region: null };
|
||||
}
|
||||
const dashRegion = parseLegacyS3DashRegion(next);
|
||||
if (dashRegion) {
|
||||
return { service: 's3', region: dashRegion };
|
||||
}
|
||||
}
|
||||
// On AWS hosts the region is otherwise always the last label before the domain
|
||||
// suffix, so a second-to-last region marks the region-middle and region-first
|
||||
// shapes (`<domain>.<region>.es.amazonaws.com`, `<region>.queue.amazonaws.com`),
|
||||
// which put the service right of the region. Bucket-qualified S3 interface
|
||||
// endpoints (`<bucket>.vpce-<id>.s3.<region>.vpce.amazonaws.com`) also carry a
|
||||
// second-to-last region but their trailing `vpce` label is not a service.
|
||||
service = next;
|
||||
} else if (regionIdx >= 2) {
|
||||
// AWS hostnames place the service label immediately left of the region
|
||||
// (qualifiers like bucket/API-id/access-point names sit further left);
|
||||
// dual-stack endpoints interpose a 'dualstack' qualifier — skip it.
|
||||
let serviceIdx = regionIdx - 1;
|
||||
if (labels[serviceIdx] === 'dualstack') serviceIdx--;
|
||||
service = labels[serviceIdx];
|
||||
} else if (regionIdx === -1) {
|
||||
// Legacy region-less S3 hosts (`<bucket>.s3.amazonaws.com`,
|
||||
// `<bucket>.s3-accelerate[.dualstack].amazonaws.com`) put the service last.
|
||||
// The family is closed, so only adopt a trailing label that belongs to it —
|
||||
// a host with a typo'd (non-region-shaped) region keeps its first-label service.
|
||||
let serviceIdx = labels.length - 1;
|
||||
if (labels[serviceIdx] === 'dualstack' && serviceIdx > 0) serviceIdx--;
|
||||
const candidate = labels[serviceIdx];
|
||||
const dashRegion = parseLegacyS3DashRegion(candidate);
|
||||
if (dashRegion) {
|
||||
return { service: 's3', region: dashRegion };
|
||||
}
|
||||
if (serviceIdx > 0 && (candidate === 's3' || candidate === 's3-accelerate')) {
|
||||
service = candidate;
|
||||
}
|
||||
}
|
||||
return { service, region };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,8 +381,9 @@ export function parseAwsUrl(url: URL): { region: string | null; service: string
|
||||
* an AWS endpoint host, an unrecognized label (a malformed/mistyped host, or
|
||||
* an odd endpoint shape the parser mis-split) throws a UserError, so the
|
||||
* request fails fast with a clear message instead of signing with a bad
|
||||
* region. On a custom (non-AWS) host, the second DNS label is usually not a
|
||||
* region at all, so the credential region is kept instead.
|
||||
* region. On a custom (non-AWS) host, an unrecognized region-shaped label
|
||||
* is not authoritative (proxies and S3-compatible stores use their own
|
||||
* region names), so the credential region is kept instead.
|
||||
*/
|
||||
function resolveServiceAndRegion(
|
||||
url: URL,
|
||||
@@ -630,8 +732,9 @@ export async function signOptions(
|
||||
|
||||
const httpRequest = buildSmithyHttpRequest(signOpts, method);
|
||||
|
||||
// signOpts.service is set only when the signing name differs from the endpoint name
|
||||
// (e.g. bedrock-runtime → bedrock). Fall back to the first label of the hostname.
|
||||
// awsGetSignInOptionsAndUpdateRequest always sets signOpts.service to the resolved
|
||||
// signing name; the raw first-hostname-label fallback is defensive only and does
|
||||
// not normalize (e.g. bedrock-runtime → bedrock).
|
||||
const service = signOpts.service ?? httpRequest.hostname.split('.')[0];
|
||||
const region = signOpts.region ?? 'us-east-1';
|
||||
|
||||
|
||||
@@ -256,6 +256,43 @@ describe('Aws Credential', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dual-stack and FIPS endpoints', () => {
|
||||
it.each([
|
||||
{
|
||||
host: 's3.dualstack.us-east-1.amazonaws.com',
|
||||
path: '/bucket/key',
|
||||
},
|
||||
{
|
||||
host: 's3-fips.us-east-1.amazonaws.com',
|
||||
path: '/bucket/key',
|
||||
},
|
||||
])(
|
||||
'should sign $host requests with the s3 service and the host-derived region',
|
||||
async ({ host, path }) => {
|
||||
const result = await aws.authenticate(credentials, {
|
||||
...requestOptions,
|
||||
baseURL: '',
|
||||
url: `https://${host}${path}`,
|
||||
});
|
||||
|
||||
// The credential region is eu-central-1; the endpoint's region must win.
|
||||
// The resolved s3 service must also get the S3-specific signer rules.
|
||||
expect(MockSignatureV4).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
service: 's3',
|
||||
region: 'us-east-1',
|
||||
applyChecksum: true,
|
||||
uriEscapePath: false,
|
||||
}),
|
||||
);
|
||||
expect(mockSmithySignFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ hostname: host, path }),
|
||||
);
|
||||
expect(result.url).toBe(`https://${host}${path}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle an IRequestOptions object with form instead of body', async () => {
|
||||
const result = await aws.authenticate({ ...credentials }, {
|
||||
...requestOptions,
|
||||
|
||||
Reference in New Issue
Block a user