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:
Csaba Tuncsik
2026-07-15 13:43:20 +02:00
committed by GitHub
parent 318925a421
commit 0e8420bbff
3 changed files with 723 additions and 15 deletions
@@ -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,