From 913be953c3309a01a05aa786364ebc36a1203f9c Mon Sep 17 00:00:00 2001 From: Rafael Laidlaw Date: Sun, 27 Jul 2025 10:23:15 -0400 Subject: [PATCH] fix: allow aws signature to work with query params (#5231) Co-authored-by: Twix Co-authored-by: Anwarul Islam Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com> --- .../types/__tests__/aws-signature.spec.ts | 431 ++++++++++++++++++ .../src/helpers/auth/types/aws-signature.ts | 141 ++++-- 2 files changed, 530 insertions(+), 42 deletions(-) create mode 100644 packages/hoppscotch-common/src/helpers/auth/types/__tests__/aws-signature.spec.ts diff --git a/packages/hoppscotch-common/src/helpers/auth/types/__tests__/aws-signature.spec.ts b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/aws-signature.spec.ts new file mode 100644 index 00000000..54d94040 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/aws-signature.spec.ts @@ -0,0 +1,431 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest" +import { makeRESTRequest } from "@hoppscotch/data" +import { + generateAwsSignatureAuthHeaders, + generateAwsSignatureAuthParams, +} from "../aws-signature" +import type { HoppRESTAuth, Environment } from "@hoppscotch/data" + +vi.mock("aws4fetch", () => ({ + AwsV4Signer: vi.fn().mockImplementation((config) => ({ + sign: vi.fn().mockResolvedValue({ + headers: new Map([ + [ + "Authorization", + "AWS4-HMAC-SHA256 Credential=test-key/20240101/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=test-signature", + ], + ["X-Amz-Date", "20240101T120000Z"], + ["Host", "s3.amazonaws.com"], + ]), + url: new URL(config.url), + }), + })), +})) + +vi.mock("~/helpers/utils/EffectiveURL", () => ({ + getFinalBodyFromRequest: vi.fn().mockReturnValue("test body"), +})) + +describe("AWS Signature Auth", () => { + const mockEnvVars: Environment["variables"] = [ + { + key: "AWS_ACCESS_KEY", + secret: false, + initialValue: "test-access-key", + currentValue: "test-access-key", + }, + { + key: "AWS_SECRET_KEY", + secret: true, + initialValue: "test-secret-key", + currentValue: "test-secret-key", + }, + { + key: "AWS_REGION", + secret: false, + initialValue: "us-east-1", + currentValue: "us-east-1", + }, + { + key: "AWS_SERVICE", + secret: false, + initialValue: "s3", + currentValue: "s3", + }, + ] + + // Helper function to create base auth configuration + const createBaseAuth = ( + overrides: Partial = {} + ): HoppRESTAuth & { authType: "aws-signature" } => ({ + authType: "aws-signature", + authActive: true, + addTo: "HEADERS", + accessKey: "test-access-key", + secretKey: "test-secret-key", + region: "us-east-1", + serviceName: "s3", + serviceToken: "", + ...overrides, + }) + + // Helper function to create base request + const createBaseRequest = ( + overrides: Partial[0]> = {} + ) => { + const baseRequest: Parameters[0] = { + method: "GET", + endpoint: "https://s3.amazonaws.com/bucket/key", + name: "Test Request", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { + authType: "inherit", + authActive: true, + }, + body: { + contentType: null, + body: null, + }, + requestVariables: [], + responses: {}, + } + + return makeRESTRequest({ ...baseRequest, ...overrides }) + } + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date("2024-01-01T12:00:00Z")) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe("generateAwsSignatureAuthHeaders", () => { + test("should return empty array when addTo is not HEADERS", async () => { + const auth = createBaseAuth({ addTo: "QUERY_PARAMS" }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthHeaders( + auth, + request, + mockEnvVars + ) + expect(result).toEqual([]) + }) + + test("should generate AWS signature headers correctly", async () => { + const auth = createBaseAuth() // uses default HEADERS addTo + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthHeaders( + auth, + request, + mockEnvVars + ) + + expect(result).toHaveLength(3) + expect(result).toEqual([ + { + active: true, + key: "Authorization", + value: + "AWS4-HMAC-SHA256 Credential=test-key/20240101/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=test-signature", + description: "", + }, + { + active: true, + key: "X-Amz-Date", + value: "20240101T120000Z", + description: "", + }, + { + active: true, + key: "Host", + value: "s3.amazonaws.com", + description: "", + }, + ]) + }) + + test("should parse template strings for auth parameters", async () => { + const auth = createBaseAuth({ + accessKey: "<>", + secretKey: "<>", + region: "<>", + serviceName: "<>", + }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthHeaders( + auth, + request, + mockEnvVars + ) + expect(result).toHaveLength(3) + }) + + test("should handle request parameters and sort them alphabetically", async () => { + const auth = createBaseAuth() + const request = createBaseRequest({ + params: [ + { active: true, key: "z-param", value: "value1", description: "" }, + { active: true, key: "a-param", value: "value2", description: "" }, + { active: false, key: "inactive", value: "value3", description: "" }, + { active: true, key: "", value: "empty-key", description: "" }, + ], + }) + + const result = await generateAwsSignatureAuthHeaders( + auth, + request, + mockEnvVars + ) + expect(result).toHaveLength(3) + }) + + test("should handle session token when provided", async () => { + const auth = createBaseAuth({ serviceToken: "test-session-token" }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthHeaders( + auth, + request, + mockEnvVars + ) + expect(result).toHaveLength(3) + }) + + test("should default to us-east-1 region when region is empty", async () => { + const auth = createBaseAuth({ region: "" }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthHeaders( + auth, + request, + mockEnvVars + ) + expect(result).toHaveLength(3) + }) + }) + + describe("generateAwsSignatureAuthParams", () => { + test("should return empty array when addTo is not QUERY_PARAMS", async () => { + const auth = createBaseAuth({ addTo: "HEADERS" }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthParams( + auth, + request, + mockEnvVars + ) + expect(result).toEqual([]) + }) + + test("should generate AWS signature query parameters correctly", async () => { + const { AwsV4Signer } = await import("aws4fetch") + vi.mocked(AwsV4Signer).mockImplementation( + (config) => + ({ + sign: vi.fn().mockResolvedValue({ + headers: new Map(), + url: new URL( + config.url + + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test-key%2F20240101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20240101T120000Z&X-Amz-SignedHeaders=host&X-Amz-Signature=test-signature" + ), + }), + }) as any + ) + + const auth = createBaseAuth({ addTo: "QUERY_PARAMS" }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthParams( + auth, + request, + mockEnvVars + ) + + expect(result).toHaveLength(5) + expect(result).toEqual([ + { + active: true, + key: "X-Amz-Algorithm", + value: "AWS4-HMAC-SHA256", + description: "", + }, + { + active: true, + key: "X-Amz-Credential", + value: "test-key/20240101/us-east-1/s3/aws4_request", + description: "", + }, + { + active: true, + key: "X-Amz-Date", + value: "20240101T120000Z", + description: "", + }, + { + active: true, + key: "X-Amz-SignedHeaders", + value: "host", + description: "", + }, + { + active: true, + key: "X-Amz-Signature", + value: "test-signature", + description: "", + }, + ]) + }) + + test("should exclude original request parameters from result", async () => { + const { AwsV4Signer } = await import("aws4fetch") + vi.mocked(AwsV4Signer).mockImplementation( + (config) => + ({ + sign: vi.fn().mockResolvedValue({ + headers: new Map(), + url: new URL( + config.url + + "?original-param=value&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=test-signature" + ), + }), + }) as any + ) + + const auth = createBaseAuth({ addTo: "QUERY_PARAMS" }) + const request = createBaseRequest({ + params: [ + { + active: true, + key: "original-param", + value: "value", + description: "", + }, + ], + }) + + const result = await generateAwsSignatureAuthParams( + auth, + request, + mockEnvVars + ) + + // only return AWS signature parameters, not the original parameter + expect(result).toHaveLength(2) + expect(result.find((p) => p.key === "original-param")).toBeUndefined() + expect(result.find((p) => p.key === "X-Amz-Algorithm")).toBeDefined() + expect(result.find((p) => p.key === "X-Amz-Signature")).toBeDefined() + }) + + test("should handle template strings in endpoint", async () => { + const { AwsV4Signer } = await import("aws4fetch") + vi.mocked(AwsV4Signer).mockImplementation( + (config) => + ({ + sign: vi.fn().mockResolvedValue({ + headers: new Map(), + url: new URL( + config.url + + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=test-signature" + ), + }), + }) as any + ) + + const auth = createBaseAuth({ addTo: "QUERY_PARAMS" }) + + const envVarsWithHost: Environment["variables"] = [ + ...mockEnvVars, + { + key: "HOST", + secret: false, + initialValue: "s3.amazonaws.com", + currentValue: "s3.amazonaws.com", + }, + ] + + const request = createBaseRequest({ + endpoint: "https://<>/bucket/key", + }) + + const result = await generateAwsSignatureAuthParams( + auth, + request, + envVarsWithHost + ) + expect(result).toHaveLength(2) + }) + + test("should sort existing parameters alphabetically before signing", async () => { + const { AwsV4Signer } = await import("aws4fetch") + vi.mocked(AwsV4Signer).mockImplementation( + (config) => + ({ + sign: vi.fn().mockResolvedValue({ + headers: new Map(), + url: new URL( + config.url + + "?z-param=value1&a-param=value2&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=test-signature" + ), + }), + }) as any + ) + + const auth = createBaseAuth({ addTo: "QUERY_PARAMS" }) + const request = createBaseRequest({ + params: [ + { active: true, key: "z-param", value: "value1", description: "" }, + { active: true, key: "a-param", value: "value2", description: "" }, + { active: false, key: "inactive", value: "value3", description: "" }, + ], + }) + + const result = await generateAwsSignatureAuthParams( + auth, + request, + mockEnvVars + ) + + // exclude original parameters and only return AWS signature parameters + expect(result).toHaveLength(2) + expect(result.find((p) => p.key === "z-param")).toBeUndefined() + expect(result.find((p) => p.key === "a-param")).toBeUndefined() + expect(result.find((p) => p.key === "inactive")).toBeUndefined() + }) + + test("should handle empty or missing session token", async () => { + const { AwsV4Signer } = await import("aws4fetch") + vi.mocked(AwsV4Signer).mockImplementation( + (config) => + ({ + sign: vi.fn().mockResolvedValue({ + headers: new Map(), + url: new URL( + config.url + + "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=test-signature" + ), + }), + }) as any + ) + + const auth = createBaseAuth({ addTo: "QUERY_PARAMS" }) + const request = createBaseRequest() + + const result = await generateAwsSignatureAuthParams( + auth, + request, + mockEnvVars + ) + expect(result).toHaveLength(2) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/auth/types/aws-signature.ts b/packages/hoppscotch-common/src/helpers/auth/types/aws-signature.ts index 4b253c9b..1a0568ec 100644 --- a/packages/hoppscotch-common/src/helpers/auth/types/aws-signature.ts +++ b/packages/hoppscotch-common/src/helpers/auth/types/aws-signature.ts @@ -3,12 +3,90 @@ import { HoppRESTAuth, HoppRESTHeader, HoppRESTParam, + HoppRESTParams, HoppRESTRequest, parseTemplateString, } from "@hoppscotch/data" import { AwsV4Signer } from "aws4fetch" import { getFinalBodyFromRequest } from "~/helpers/utils/EffectiveURL" +type SignOptions = { + auth: HoppRESTAuth & { authType: "aws-signature" } + request: HoppRESTRequest + envVars: Environment["variables"] + signQuery?: boolean +} + +function processQueryParameters( + params: HoppRESTParams, + envVars: Environment["variables"], + baseUrl: string +): { url: URL; sortedParams: Array<{ key: string; value: string }> } { + const url = new URL(baseUrl) + + // add existing query parameters from the request in lexicographical order as per AWS documentation + const sortedParams = params + .filter((param) => param.active && param.key !== "") + .map((param) => ({ + key: parseTemplateString(param.key, envVars), + value: parseTemplateString(param.value, envVars), + })) + .sort((a, b) => a.key.localeCompare(b.key)) + + sortedParams.forEach((param) => { + url.searchParams.append(param.key, param.value) + }) + + return { url, sortedParams } +} + +async function signAWSRequest({ + auth, + request, + envVars, + signQuery = false, +}: SignOptions) { + const currentDate = new Date() + const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, "") + + const baseUrl = parseTemplateString(request.endpoint, envVars) + const { url, sortedParams } = processQueryParameters( + request.params, + envVars, + baseUrl + ) + + const accessKeyId = parseTemplateString(auth.accessKey, envVars) + const secretAccessKey = parseTemplateString(auth.secretKey, envVars) + const region = parseTemplateString(auth.region, envVars) ?? "us-east-1" + const service = parseTemplateString(auth.serviceName, envVars) + const sessionToken = auth.serviceToken + ? parseTemplateString(auth.serviceToken, envVars) + : undefined + + const signerConfig: ConstructorParameters[0] = { + method: request.method, + datetime: amzDate, + accessKeyId, + secretAccessKey, + region, + service, + sessionToken, + url: url.toString(), + signQuery, + } + + if (!signQuery) { + const body = getFinalBodyFromRequest(request, envVars) + signerConfig.body = body?.toString() + } + + const signer = new AwsV4Signer(signerConfig) + const sign = await signer.sign() + + return { sign, sortedParams } +} + export async function generateAwsSignatureAuthHeaders( auth: HoppRESTAuth & { authType: "aws-signature" }, request: HoppRESTRequest, @@ -16,33 +94,19 @@ export async function generateAwsSignatureAuthHeaders( ): Promise { if (auth.addTo !== "HEADERS") return [] - const currentDate = new Date() - const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, "") - const { method, endpoint } = request - - const body = getFinalBodyFromRequest(request, envVars) - - const signer = new AwsV4Signer({ - method: method, - body: body?.toString(), - datetime: amzDate, - accessKeyId: parseTemplateString(auth.accessKey, envVars), - secretAccessKey: parseTemplateString(auth.secretKey, envVars), - region: parseTemplateString(auth.region, envVars) ?? "us-east-1", - service: parseTemplateString(auth.serviceName, envVars), - sessionToken: - auth.serviceToken && parseTemplateString(auth.serviceToken, envVars), - url: parseTemplateString(endpoint, envVars), + const { sign } = await signAWSRequest({ + auth, + request, + envVars, + signQuery: false, }) - - const sign = await signer.sign() const headers: HoppRESTHeader[] = [] sign.headers.forEach((value, key) => { headers.push({ active: true, - key: key, - value: value, + key, + value, description: "", }) }) @@ -57,32 +121,25 @@ export async function generateAwsSignatureAuthParams( ): Promise { if (auth.addTo !== "QUERY_PARAMS") return [] - const currentDate = new Date() - const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, "") - - const signer = new AwsV4Signer({ - method: request.method, - datetime: amzDate, + const { sign, sortedParams } = await signAWSRequest({ + auth, + request, + envVars, signQuery: true, - accessKeyId: parseTemplateString(auth.accessKey, envVars), - secretAccessKey: parseTemplateString(auth.secretKey, envVars), - region: parseTemplateString(auth.region, envVars) ?? "us-east-1", - service: parseTemplateString(auth.serviceName, envVars), - sessionToken: - auth.serviceToken && parseTemplateString(auth.serviceToken, envVars), - url: parseTemplateString(request.endpoint, envVars), }) - - const sign = await signer.sign() const params: HoppRESTParam[] = [] + const originalParams = new Set(sortedParams.map((param) => param.key)) + for (const [key, value] of sign.url.searchParams) { - params.push({ - active: true, - key: key, - value: value, - description: "", - }) + if (!originalParams.has(key)) { + params.push({ + active: true, + key, + value, + description: "", + }) + } } return params