Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: AWS region selection for SigV4 signing unmanaged endpoints #14037

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions packages/nodes-base/credentials/Aws.credentials.ts
Original file line number Diff line number Diff line change
@@ -214,9 +214,63 @@ export type AwsCredentialsType = {
// 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)
//
// The region we return will only be used to SigV4-sign the AWS API request
// with our credentials. If we return one, it will override the default
// region set in the credentials. It's imperative that we do this if we
// detect that the endpoint is for a specific region, or a global service, so
// that the request signature is valid, regardless of the region set in the
// credentials.
function parseAwsUrl(url: URL): { region: AWSRegion | null; service: string } {
const [service, region] = url.hostname.replace('amazonaws.com', '').split('.');
return { service, region: region as AWSRegion };
// We are explicitly assuming that the AWS URL Suffix for any supported
// AWS partition contains two parts - i.e. amazonaws.com
const parts = url.hostname.split('.');

// ¯\_(ツ)_/¯
if (parts.length < 3) {
console.error(`Invalid AWS URL: ${url.hostname}`);
return { service: url.hostname, region: null };
}

if (parts.length === 3) {
// If the URL has three parts, it's a global service
// and the region can only be us-east-1
return { service: parts.slice(-3)[0], region: 'us-east-1' };
}

// parts.length > 3 && s3
// S3 is a special case, as it can be global (bucket.s3.amazonaws.com) or
// regional (bucket.s3-{region}.amazonaws.com), which in neither case is
// similar to other services.
//
// Let's get the last URL part before the suffix, and check if it's S3 or
// approximately s3-{region}.
//
// * If it is just S3 we have no way to know the required region. We have to
// return a null region so that the region is sourced from the credentials.
if (parts.slice(-3)[0] === 's3') {
return { service: 's3', region: null };
}
// * If it is s3-region we can return the region part.
if (parts.slice(-3)[0].match(/^s3-[a-z0-9-]+$/)) {
return {
service: 's3',
region: parts.slice(-3)[0].replace('s3-', '') as AWSRegion,
};
}

// parts.length > 3 && !s3
// The expected case here is for a parts.length of 4, where the URL is
// {service}.{region}.amazonaws.com. We'll return the two parts before the
// amazonaws.com part as service and region.
//
// We shouldn't get a length of greater than 4, but if we do, we'll return
// the two that precede the amazonaws.com part as service and region.
// We're doing a best-effort guess here, and we can't really do anything
// else other than throw an error so better to assume
// something-odd.service.region.amazonaws.com rather than some other
// combination.
return { service: parts.slice(-4)[0], region: parts.slice(-3)[0] as AWSRegion };
}

export class Aws implements ICredentialType {
@@ -438,10 +492,10 @@ export class Aws implements ICredentialType {
endpointString = credentials.rekognitionEndpoint;
} else if (service === 'sqs' && credentials.sqsEndpoint) {
endpointString = credentials.sqsEndpoint;
} else if (service) {
endpointString = `https://${service}.${region}.amazonaws.com`;
} else if (service === 'ssm' && credentials.ssmEndpoint) {
endpointString = credentials.ssmEndpoint;
} else if (service) {
endpointString = `https://${service}.${region}.amazonaws.com`;
}
endpoint = new URL(endpointString!.replace('{region}', region) + path);
} else {
104 changes: 96 additions & 8 deletions packages/nodes-base/credentials/test/Aws.credentials.test.ts
Original file line number Diff line number Diff line change
@@ -120,24 +120,112 @@ describe('Aws Credential', () => {
});

it('should return correct options for a global AWS service', async () => {
const result = await aws.authenticate(credentials, {
...requestOptions,
url: 'https://iam.amazonaws.com',
baseURL: '',
});
const result = await aws.authenticate(
{ ...credentials },
{ ...requestOptions, url: 'https://jeff.amazonaws.com', baseURL: '' },
);

expect(mockSign).toHaveBeenCalledWith(
{
...signOpts,
baseURL: '',
path: '/',
host: 'jeff.amazonaws.com',
url: 'https://jeff.amazonaws.com',
region: 'us-east-1',
},
securityHeaders,
);
expect(result.method).toBe('POST');
expect(result.url).toBe('https://jeff.amazonaws.com/');
});

it('should return correct options for the global S3 endpoint', async () => {
const result = await aws.authenticate(
{ ...credentials },
{ ...requestOptions, url: 'https://jeff.s3.amazonaws.com', baseURL: '' },
);

expect(mockSign).toHaveBeenCalledWith(
{
...signOpts,
baseURL: '',
path: '/',
host: 'jeff.s3.amazonaws.com',
url: 'https://jeff.s3.amazonaws.com',
region: 'eu-central-1',
},
securityHeaders,
);
expect(result.method).toBe('POST');
expect(result.url).toBe('https://jeff.s3.amazonaws.com/');
});

it('should return correct options for the regional S3 endpoint', async () => {
const result = await aws.authenticate(
{ ...credentials },
{ ...requestOptions, url: 'https://jeff.s3-ap-southeast-2.amazonaws.com', baseURL: '' },
);

expect(mockSign).toHaveBeenCalledWith(
{
...signOpts,
baseURL: '',
path: '/',
host: 'jeff.s3-ap-southeast-2.amazonaws.com',
url: 'https://jeff.s3-ap-southeast-2.amazonaws.com',
region: 'ap-southeast-2',
},
securityHeaders,
);
expect(result.method).toBe('POST');
expect(result.url).toBe('https://jeff.s3-ap-southeast-2.amazonaws.com/');
});

it('should use the whole URL as host if the URL is invalid', async () => {
const result = await aws.authenticate(
{ ...credentials },
{ ...requestOptions, url: 'https://amazonaws.com', baseURL: '' },
);

expect(mockSign).toHaveBeenCalledWith(
{
...signOpts,
baseURL: '',
path: '/',
host: 'amazonaws.com',
url: 'https://amazonaws.com',
region: 'eu-central-1',
},
securityHeaders,
);
expect(result.method).toBe('POST');
expect(result.url).toBe('https://amazonaws.com/');
});

it('should extract the region and service from a long URL', async () => {
const result = await aws.authenticate(
{ ...credentials },
{
...requestOptions,
url: 'https://jeffrey.the.space.donkey.ap-southeast-2.amazonaws.com',
baseURL: '',
},
);

expect(mockSign).toHaveBeenCalledWith(
{
...signOpts,
baseURL: '',
path: '/',
host: 'iam.amazonaws.com',
url: 'https://iam.amazonaws.com',
host: 'jeffrey.the.space.donkey.ap-southeast-2.amazonaws.com',
url: 'https://jeffrey.the.space.donkey.ap-southeast-2.amazonaws.com',
region: 'ap-southeast-2',
},
securityHeaders,
);
expect(result.method).toBe('POST');
expect(result.url).toBe('https://iam.amazonaws.com/');
expect(result.url).toBe('https://jeffrey.the.space.donkey.ap-southeast-2.amazonaws.com/');
});
});
});