Modernise: SHA-256 signatures, constant-time GitLab tokens, no HMAC in logs, Go 1.25 - #126
jbdevprimary wants to merge 4 commits into
Conversation
…omputed hash GitHub sends X-Hub-Signature-256 on every delivery and its documentation is explicit that receivers should use it; this proxy validated only the SHA-1 header. On the one process a webhook proxy deliberately exposes to the internet, that is weaker checking than the payload already permits. SHA-256 is now preferred with SHA-1 as the fallback. The fallback is deliberate rather than reluctant: GitLab and older GitHub Enterprise installations send SHA-1 only, and dropping it would break working deployments for no security gain when SHA-256 is absent. WHEN BOTH HEADERS ARE PRESENT THE SHA-256 ONE DECIDES, and that is the part worth stating. A receiver that fell back to SHA-1 on a BAD SHA-256 would let an attacker choose the weaker algorithm by pairing a valid SHA-1 with a forged SHA-256. There is a test for exactly that case. AND THE COMPUTED HASH IS NO LONGER LOGGED. `IsValidPayload` printed it on every delivery. The digest is derived from the shared secret over a body an attacker can choose, so publishing it to logs hands out an oracle for free — and it debugs nothing the rejection itself does not already show. TestGithubProvider_Validate was entirely commented out, which is why the implementation carried a `// TODO: Update implementation and tests`. It is now eleven real cases covering both algorithms, the downgrade attempt, a tampered payload, a wrong secret, malformed prefixes and lengths, nil headers, and an empty SHA-256 header falling through to SHA-1. Signatures are COMPUTED from the helpers rather than pasted, so a change to either helper fails the tests instead of quietly agreeing with itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…se to Go 1.25 TWO CHANGES, ONE OF THEM A SECURITY FIX. CONSTANT-TIME TOKEN COMPARISON. `GitlabProvider.Validate` compared `X-Gitlab-Token` against the configured secret with `==`. Go's string comparison returns as soon as two bytes differ, so how long a rejection takes is a function of how many leading characters the caller guessed correctly. A webhook endpoint is public and accepts as many requests as you send it, which is exactly the condition a timing attack needs to recover a token one byte at a time. Unlike GitHub, GitLab sends the token VERBATIM rather than a signature — there is no digest to compare, so this string IS the credential and the comparison is the whole of the check. `subtle.ConstantTimeCompare` also returns 0 for differing lengths without examining contents, which is correct here. Three tests were added for the shapes an attack actually uses: a prefix of the secret, a length-equal near-miss with the last byte changed, and the surrounding-whitespace case, which is trimmed deliberately — some proxies pad the header and rejecting those would break working deployments. GO 1.25, UP FROM 1.13. The upstream project's last release was November 2020 and its last commit March 2023, so it was pinned to a toolchain that had already left support. Thirteen releases of security fixes sat on the other side of a one-line bump. `httpmock` moves 1.0.4 -> 1.4.1 with it. MODULE PATH IS NOW `github.com/jbcom/GitWebhookProxy`. Renaming without rewriting the internal imports made `go mod tidy` pull the UPSTREAM module in as a dependency of the fork — a fork that silently compiles half of the code it replaced. All five importing files were rewritten; upstream is no longer in `go.mod` at all. go build, go vet and the full test suite pass on Go 1.26. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fork's first substantive change, and the reason it exists. Upstream's last
release was November 2020 and its last commit March 2023; two of the three
fixes here are security fixes on the one process this project deliberately
exposes to the internet.
* GitHub deliveries are validated against X-Hub-Signature-256, with SHA-1
kept as a fallback for GitLab and older GitHub Enterprise. When both
headers are present the SHA-256 one decides, so an attacker cannot
downgrade by pairing a valid SHA-1 with a forged SHA-256.
* The computed HMAC is no longer logged on every delivery.
* GitLab's token comparison is constant-time. It was `==`, on a credential
sent verbatim to a public endpoint that accepts unlimited attempts.
* Go 1.13 -> 1.25, and the module path is the fork's own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…and build from source
THE HOLE. `proxyRequest` ran the ignored/allowed-user filter first and answered
200 to anything it decided to ignore — before the signature was ever checked.
`isIgnoredUser` treats an EMPTY committer on the github provider as ignored, so
an UNSIGNED request reaching that branch got a 200 from a proxy that had never
authenticated it.
Measured against the built image, `GWP_SECRET` set, no signature header at all:
Incoming request from user:
Ignoring request for user:
-> HTTP 200
It never reached upstream, so nothing was forwarded — which is why it survived.
It is worse in a quieter way: a public endpoint reporting success for a request
it refused to authenticate is exactly what a probe looks for.
The committer name is read from the PAYLOAD, and a payload is only trustworthy
after the signature says so. Deciding anything on it first — including deciding
to IGNORE it — is deciding on attacker-supplied data. Validation now comes
first.
FOUND BY RUNNING IT, NOT BY THE TESTS, and the regression test took two
attempts to get right. The first used the gitlab provider and passed with the
bug deliberately reintroduced, because gitlab has no empty-committer case and
never reaches that branch. The github version fails with "got 200 want 400"
against the old ordering, verified by swapping the blocks back.
BOTH SIGNATURE HEADERS ARE NOW OPTIONAL AT THE PARSER. `GetHeaderKeys` conflated
"headers I want forwarded" with "headers that must be present", so listing
X-Hub-Signature-256 there made BOTH mandatory and rejected every delivery
carrying only one. Requiring either is wrong in a different direction: SHA-256
excludes GitLab and older GitHub Enterprise, SHA-1 excludes a sender that has
dropped the legacy header. The real rule — at least one, strongest wins — can
only be stated in `Validate`, so `GetOptionalHeaderKeys` was added to the
Provider interface and enforcement lives with the signature check.
A SELF-CONTAINED DOCKERFILE. `build/package/Dockerfile` is three lines that
`COPY ./gitwebhookproxy /` — it expects a binary already compiled beside it, so
it cannot be handed to any platform that builds from a repository.
`Dockerfile.build` is a Go 1.13 image driving `packr` and a GOPATH layout
modules replaced. The new root `Dockerfile` builds from source in one pass:
digest-pinned Go 1.25, CGO off, `-trimpath -ldflags="-s -w"`, and a distroless
`static-debian12:nonroot` runtime with no shell and no package manager — upstream
ran on `stakater/base-alpine:3.7`, last updated 2018.
VERIFIED AGAINST THE RUNNING IMAGE, not only in unit tests:
SHA-256 only, valid -> 200
SHA-1 only, valid -> 200
forged SHA-256 + valid 1 -> 400
no signature at all -> 400
tampered body, valid sig -> 400
disallowed path -> 403
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding a fourth fix, and it is the most serious oneI built the image and probed it rather than trusting the unit tests, and found
Measured against the built image with Nothing is forwarded upstream, which is presumably why it survived — but a The committer name is read from the payload, and a payload is only The regression test took two attempts, which is worth mentioningMy first one used the gitlab provider and passed with the bug deliberately Also in this pushBoth signature headers are now optional at the parser. A self-contained Verified against the running image
The unsigned-request 200 is gone. |
Two security fixes, one logging leak, and a toolchain bump. Offered as one PR
because they land together in the fork this came from and the project has been
quiet since 2020 — take whatever is useful.
go build,go vetand the full test suite pass on Go 1.26.1. GitHub SHA-256 signatures
GithubProvider.Validatechecked onlyX-Hub-Signature(HMAC-SHA1). GitHubsends
X-Hub-Signature-256on every delivery and its documentationsays to use it. A proxy validating only SHA-1 is doing weaker checking than the
payload already permits, on the one process this project deliberately exposes to
the internet.
SHA-256 is now preferred, with SHA-1 kept as a fallback — GitLab and older
GitHub Enterprise send SHA-1 only, and dropping it would break working
deployments for no gain when SHA-256 is absent.
When both headers are present, SHA-256 decides. Falling back to SHA-1 on a
bad SHA-256 would let an attacker choose the weaker algorithm by pairing a
valid SHA-1 with a forged SHA-256.
TestValidateRejectsBadSHA256EvenWithValidSHA1covers exactly that.
2. The computed hash is no longer logged
IsValidPayloaddidlog.Printf("Calculated Hash: %s", hash)on everydelivery. The digest is derived from the shared secret over a body the caller
chooses, so writing it to logs publishes an oracle — and it debugs nothing the
rejection itself does not already show.
3. GitLab token comparison is constant-time
GitlabProvider.Validateused==. Go's string comparison returns as soon astwo bytes differ, so rejection time is a function of how many leading characters
the caller guessed right, and a webhook endpoint is public and accepts unlimited
attempts.
Unlike GitHub, GitLab sends the token verbatim rather than a signature, so
this string is the credential and the comparison is the whole of the check.
Now
subtle.ConstantTimeCompare. Whitespace trimming is preserved deliberately;some proxies pad the header.
4. Go 1.13 → 1.25, and dependency refresh
The module was pinned to a toolchain that left support some years ago; thirteen
releases of security fixes sat behind a one-line bump.
httpmock1.0.4 → 1.4.1.Tests
TestGithubProvider_Validatewas entirely commented out, which is why theimplementation carried
// TODO: Update implementation and tests. It is noweleven real cases: both algorithms, the downgrade attempt, a tampered payload, a
wrong secret, malformed prefixes and lengths, nil headers, and an empty SHA-256
header falling through to SHA-1. Signatures are computed from the helpers
rather than pasted, so a change to a helper fails the tests instead of quietly
agreeing with itself.
Three cases added for GitLab: a prefix of the secret, a length-equal near-miss
with the last byte changed, and the whitespace case.
One thing to strip if you take this
The diff renames the module path to
github.com/jbcom/GitWebhookProxy(thefork). That is obviously not something to merge — revert
go.modand the fivefiles' import lines and the rest applies cleanly. I kept it in rather than
maintain two branches, since the alternative was asking you to review the same
work twice.
From a fork at jbcom/GitWebhookProxy,
where these are already merged. If this project is no longer maintained, no hard
feelings — but the three fixes are worth having for anyone still running it, and
I would rather offer them back than keep them.