fix(HTTP Request Node): Sign Amazon Bedrock requests as 'bedrock' service (#31250)

This commit is contained in:
Michael Kret 2026-06-03 08:51:42 +03:00 committed by GitHub
parent 6bcd02a5f0
commit 9963143c57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 151 additions and 0 deletions

View File

@ -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 };

View File

@ -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,

View File

@ -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 = `<?xml version="1.0"?>
<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<AssumeRoleResult>
<Credentials>
<AccessKeyId>ASSUMED-KEY</AccessKeyId>
<SecretAccessKey>ASSUMED-SECRET</SecretAccessKey>
<SessionToken>ASSUMED-SESSION</SessionToken>
<Expiration>2099-01-01T00:00:00Z</Expiration>
</Credentials>
</AssumeRoleResult>
</AssumeRoleResponse>`;
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',
}),
);
});
});