fix: drop redundant decodeURIComponent and repair urlencoded schema validation#267
Merged
danadajian merged 2 commits intoMay 26, 2026
Conversation
…edundant decodeURIComponent
The current `URL_ENCODED` branch of `parseRequestBody` has two bugs that combine to make every valid urlencoded GitHub webhook fail:
1. `bodySchema.parse(payloadParam)` is called with a `string | null` returned by `URLSearchParams.get("payload")`, but `bodySchema` is `z.object({ payload: z.string() })` — i.e. it expects an *object* with a `payload` key. zod throws `Invalid input: expected object, received string` on every valid payload, so the function returns `undefined`. The pre-existing fixture test (`fixtures/invalid-payload-urlencoded.txt`) hides this because it only exercises an invalid payload that's expected to be rejected with 403.
2. `JSON.parse(decodeURIComponent(payload))` calls `decodeURIComponent` a second time on a value `URLSearchParams.get` has already URL-decoded. This throws `URIError: URI malformed` whenever the decoded JSON contains a literal `%` that isn't part of a valid `%XX` escape — which is common in real PR titles, commit messages, and comments ("30% threshold", "set %USERPROFILE% to ~", "WHERE x LIKE '%foo%'", "printf(\"%s\", val)").
Fixes:
- Wrap the `URLSearchParams.get("payload")` value into the `{ payload: string }` shape the schema expects, so schema validation actually runs against valid payloads.
- Drop the redundant `decodeURIComponent(payload)` — `URLSearchParams.get` already URL-decodes once, which is exactly what GitHub's single-URL-encoded webhook body needs.
Tests: 9 new cases in `lambda/proxy.test.ts` cover the percent-character regression patterns plus a control and a `%20`-preservation case, exercised both end-to-end through the handler and directly against `parseRequestBody`.
danadajian
approved these changes
May 26, 2026
Co-authored-by: Dan Adajian <danadajian@gmail.com>
Contributor
|
Thanks for the fix @lzeligman-mark43! |
Contributor
|
🎉 This PR is included in version 2.4.2 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes two bugs in
lambda/parse-request-body.tsthat together break every validapplication/x-www-form-urlencodedGitHub webhook. Both bugs were introduced by the recent094cda1(feat(repo): switch to bun) refactor that introducedURLSearchParams.Bug 1 — schema is parsed against the wrong shape
URLSearchParams.getreturns the value of thepayload=field as astring | null.bodySchemais declared asz.object({ payload: z.string() }), so zod throwsInvalid input: expected object, received stringandparseRequestBodyreturnsundefined. The handler then 403s every valid urlencoded GitHub webhook.The existing fixture-based test (
fixtures/invalid-payload-urlencoded.txt) hides this because the fixture is deliberately invalid and the test only checks that invalid payloads are rejected — it does not exercise a valid urlencoded payload's success path.Bug 2 — redundant
decodeURIComponentthrows on literal%URLSearchParams.getalready URL-decodes the value once (%7B→{, etc.). CallingdecodeURIComponenta second time on the already-decoded JSON string throwsURIError: URI malformedwhenever the JSON contains a bare%that doesn't begin a valid%XXescape — extremely common in real user content:30% threshold rollout,100% complete%rate: 5%set %USERPROFILE% to ~,%PATH%LIKEwildcardWHERE name LIKE '%foo%'printf("%s\n", val),log.Printf("%v", obj)%followed by non-hexlook here: %g and %hGitHub sends webhook bodies single-URL-encoded per the form-urlencoded spec; the second decode was never needed for correct decoding of real traffic.
Fix
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);params.get("payload")into the{ payload: string }shapebodySchemaexpects, so validation actually runs.decodeURIComponentso literal%characters in user content no longer crash the parse.Tests
Adds 9 new test cases in
lambda/proxy.test.ts:handlerdriving a urlencoded webhook whose JSON contains literal%characters (multiple patterns combined).describe("parseRequestBody — urlencoded payloads with literal '%' characters", …)block exercisingparseRequestBodydirectly for each of the user-content patterns listed in the table above, plus:%20preservation test confirming that literal%20in a JSON string value is preserved verbatim (not re-decoded to a space), which is the correct semantic now that the outerURLSearchParams.getis the sole decode pass.Local run:
22 pass, 0 fail(12 existing + 10 new).Backwards-compatibility analysis
The only scenario where removing the second decode would change behavior is a hypothetical client that double-URL-encodes the body. GitHub never does this. Even if it happened, the lambda only inspects
enterprise.slugandsender.loginfor the routing/auth decision (both are GitHub identifier strings that cannot contain%), and the raw originalbodyis forwarded byte-for-byte to the destination viaaxios.post(url, body, …)— so the routing decision and forwarded payload are unchanged in that edge case.Notes
bun run format-checkclean.bun testpasses 22/22.