From 9963143c5759a42151f7cbddb53895eb9ced6794 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:51:42 +0300 Subject: [PATCH] fix(HTTP Request Node): Sign Amazon Bedrock requests as 'bedrock' service (#31250) --- .../credentials/common/aws/utils.ts | 34 +++++++++ .../credentials/test/Aws.credentials.test.ts | 45 ++++++++++++ .../test/AwsAssumeRole.credentials.test.ts | 72 +++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 packages/nodes-base/credentials/test/AwsAssumeRole.credentials.test.ts diff --git a/packages/nodes-base/credentials/common/aws/utils.ts b/packages/nodes-base/credentials/common/aws/utils.ts index 6701a7e0a33..c63859fc07e 100644 --- a/packages/nodes-base/credentials/common/aws/utils.ts +++ b/packages/nodes-base/credentials/common/aws/utils.ts @@ -79,6 +79,37 @@ export function parseAwsUrl(url: URL): { region: AWSRegion | null; service: stri return { service, region }; } +/** + * Maps an AWS endpoint subdomain to its SigV4 signing service name. + * + * Most AWS endpoints sign with the same name as their hostname label, but + * some service families (notably Amazon Bedrock) expose multiple endpoint + * subdomains that all sign against a single `signingName`. Without this + * mapping, `aws4` would derive the signing name from the host and AWS would + * reject the request with `SignatureDoesNotMatch`. + * + * Endpoints that already match their signing name fall through unchanged. + * + * @param service - Service name as extracted from the endpoint hostname + * @returns The SigV4 signing service name + */ +function getAwsSigningService(service: string): string { + switch (service) { + // 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 + // https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonbedrock.html + case 'bedrock-runtime': + case 'bedrock-agent': + case 'bedrock-agent-runtime': + case 'bedrock-data-automation': + case 'bedrock-data-automation-runtime': + return 'bedrock'; + default: + return service; + } +} + /** * AWS credentials test configuration for validating AWS credentials. * Uses the STS GetCallerIdentity action to verify that the provided credentials are valid. @@ -219,6 +250,8 @@ export function awsGetSignInOptionsAndUpdateRequest( bodyContent = params.toString(); contentTypeHeader = 'application/x-www-form-urlencoded'; } + + const signingService = getAwsSigningService(service); const signOpts = { ...requestOptions, headers: { @@ -230,6 +263,7 @@ export function awsGetSignInOptionsAndUpdateRequest( path, body: bodyContent, region, + ...(signingService !== service && { service: signingService }), } as unknown as Request; return { signOpts, url: endpoint.origin + path }; diff --git a/packages/nodes-base/credentials/test/Aws.credentials.test.ts b/packages/nodes-base/credentials/test/Aws.credentials.test.ts index 9ddb6afa45c..a5fb5d803da 100644 --- a/packages/nodes-base/credentials/test/Aws.credentials.test.ts +++ b/packages/nodes-base/credentials/test/Aws.credentials.test.ts @@ -144,6 +144,51 @@ describe('Aws Credential', () => { expect(result.url).toBe('https://iam.amazonaws.com/'); }); + describe('Amazon Bedrock services', () => { + it.each([ + { + host: 'bedrock-runtime.us-east-1.amazonaws.com', + path: '/model/anthropic.claude-v2/invoke', + }, + { + host: 'bedrock-agent.us-east-1.amazonaws.com', + path: '/agents/', + }, + { + host: 'bedrock-agent-runtime.us-east-1.amazonaws.com', + path: '/agents/agent-id/agentAliases/alias-id/sessions/session-id/text', + }, + { + host: 'bedrock-data-automation.us-east-1.amazonaws.com', + path: '/projects/', + }, + { + host: 'bedrock-data-automation-runtime.us-east-1.amazonaws.com', + path: '/invocations', + }, + ])( + 'should sign $host requests with the Bedrock service namespace', + async ({ host, path }) => { + const result = await aws.authenticate(credentials, { + ...requestOptions, + baseURL: '', + url: `https://${host}${path}`, + }); + + expect(mockSign).toHaveBeenCalledWith( + expect.objectContaining({ + host, + path, + region: 'us-east-1', + service: 'bedrock', + }), + securityHeaders, + ); + 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, diff --git a/packages/nodes-base/credentials/test/AwsAssumeRole.credentials.test.ts b/packages/nodes-base/credentials/test/AwsAssumeRole.credentials.test.ts new file mode 100644 index 00000000000..fb174c7a952 --- /dev/null +++ b/packages/nodes-base/credentials/test/AwsAssumeRole.credentials.test.ts @@ -0,0 +1,72 @@ +import { sign } from 'aws4'; +import type { IHttpRequestOptions } from 'n8n-workflow'; + +import { AwsAssumeRole } from '../AwsAssumeRole.credentials'; +import type { AwsAssumeRoleCredentialsType } from '../common/aws/types'; + +jest.mock('aws4', () => ({ + sign: jest.fn(), +})); + +const stsAssumeRoleResponseXml = ` + + + + ASSUMED-KEY + ASSUMED-SECRET + ASSUMED-SESSION + 2099-01-01T00:00:00Z + + +`; + +describe('AwsAssumeRole Credential', () => { + const aws = new AwsAssumeRole(); + let mockSign: jest.Mock; + let mockFetch: jest.SpyInstance; + + const credentials: AwsAssumeRoleCredentialsType = { + region: 'us-east-1', + customEndpoints: false, + roleArn: 'arn:aws:iam::123456789012:role/MyRole', + externalId: 'ext-id', + roleSessionName: 'n8n-session', + stsAccessKeyId: 'sts-key', + stsSecretAccessKey: 'sts-secret', + useSystemCredentialsForRole: false, + }; + + beforeEach(() => { + mockSign = sign as unknown as jest.Mock; + mockFetch = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(stsAssumeRoleResponseXml, { status: 200 })); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockFetch.mockRestore(); + }); + + it('should sign Bedrock requests with the bedrock service namespace', async () => { + const requestOptions: IHttpRequestOptions = { + qs: {}, + body: {}, + headers: {}, + baseURL: '', + url: 'https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-v2/invoke', + method: 'POST', + }; + + await aws.authenticate(credentials, requestOptions); + + const finalCall = mockSign.mock.calls.at(-1); + expect(finalCall?.[0]).toEqual( + expect.objectContaining({ + host: 'bedrock-runtime.us-east-1.amazonaws.com', + region: 'us-east-1', + service: 'bedrock', + }), + ); + }); +});