diff --git a/lambda/parse-request-body.ts b/lambda/parse-request-body.ts index 08b9ac5..39b9ab9 100644 --- a/lambda/parse-request-body.ts +++ b/lambda/parse-request-body.ts @@ -27,9 +27,10 @@ export function parseRequestBody( return JSON.parse(body); case CONTENT_TYPES.URL_ENCODED: const params = new URLSearchParams(body); - const payloadParam = params.get("payload"); - const { payload } = bodySchema.parse(payloadParam); - return JSON.parse(decodeURIComponent(payload)); + const { payload } = bodySchema.parse({ + payload: params.get("payload"), + }); + return JSON.parse(payload); } } catch (error) { console.error(`Error parsing request body: ${error}`); diff --git a/lambda/proxy.test.ts b/lambda/proxy.test.ts index 52c7b0d..733fa50 100644 --- a/lambda/proxy.test.ts +++ b/lambda/proxy.test.ts @@ -12,6 +12,7 @@ limitations under the License. */ import { handler } from "./proxy"; +import { parseRequestBody } from "./parse-request-body"; import type { AxiosRequestHeaders, AxiosResponse } from "axios"; import { readFileSync } from "fs"; import { Agent } from "https"; @@ -318,4 +319,86 @@ describe("proxy", () => { }); expect(axiosPostMock).toHaveBeenCalled(); }); + + it("should forward a urlencoded webhook whose JSON contains literal '%' characters", async () => { + const payloadWithPercent = { + ...VALID_PUSH_PAYLOAD, + head_commit: { + ...(VALID_PUSH_PAYLOAD as any).head_commit, + message: + "Roll out 30% threshold; reference %USERPROFILE% on Windows; WHERE x LIKE '%foo%'", + }, + }; + const urlencodedBody = + "payload=" + encodeURIComponent(JSON.stringify(payloadWithPercent)); + const destinationUrl = "https://approved.host/github-webhook/"; + const endpointId = encodeURIComponent(destinationUrl); + const event: APIGatewayProxyWithLambdaAuthorizerEvent = { + ...baseEvent, + headers: { + ...baseEvent.headers, + "content-type": "application/x-www-form-urlencoded", + }, + body: urlencodedBody, + pathParameters: { endpointId }, + }; + const result = await handler(event); + expect(result).toEqual(expectedResponseObject); + expect(axiosPostMock).toHaveBeenCalled(); + }); +}); + +describe("parseRequestBody — urlencoded payloads with literal '%' characters", () => { + const headers = { "content-type": "application/x-www-form-urlencoded" }; + + const cases: Array<{ label: string; userContent: string }> = [ + { label: "percentage in PR title", userContent: "30% threshold rollout" }, + { label: "trailing percent", userContent: "rate: 5%" }, + { + label: "Windows env var reference", + userContent: "set %USERPROFILE% to ~", + }, + { + label: "SQL LIKE wildcard", + userContent: "WHERE name LIKE '%foo%' AND status = 1", + }, + { + label: "C-style format string", + userContent: 'printf("%s\\n", val);', + }, + { label: "Go format verb", userContent: 'log.Printf("%v", obj)' }, + { + label: "bare % followed by non-hex", + userContent: "look here: %g and %h", + }, + ]; + + for (const c of cases) { + it(`parses a webhook whose JSON contains literal '%' (${c.label})`, () => { + const json = JSON.stringify({ + action: "opened", + pull_request: { title: c.userContent, body: c.userContent }, + }); + const body = "payload=" + encodeURIComponent(json); + const result = parseRequestBody(body, headers); + expect(result).toBeDefined(); + expect((result as any).pull_request.title).toBe(c.userContent); + }); + } + + it("still parses payloads with no '%' characters (control)", () => { + const json = JSON.stringify({ action: "opened", number: 42 }); + const body = "payload=" + encodeURIComponent(json); + const result = parseRequestBody(body, headers); + expect(result).toBeDefined(); + expect((result as any).number).toBe(42); + }); + + it("correctly preserves valid percent-escapes (%20 → space) inside JSON string values", () => { + const literalText = "encoded as %20 example"; + const json = JSON.stringify({ comment: { body: literalText } }); + const body = "payload=" + encodeURIComponent(json); + const result = parseRequestBody(body, headers); + expect((result as any).comment.body).toBe(literalText); + }); });