Skip to content

Modernise: SHA-256 signatures, constant-time GitLab tokens, no HMAC in logs, Go 1.25 - #126

Open
jbdevprimary wants to merge 4 commits into
stakater:masterfrom
jbcom:feat/sha256-signature-validation
Open

jbdevprimary wants to merge 4 commits into
stakater:masterfrom
jbcom:feat/sha256-signature-validation

Conversation

@jbdevprimary

@jbdevprimary jbdevprimary commented Aug 31, 2026

Copy link
Copy Markdown

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 vet and the full test suite pass on Go 1.26.

1. GitHub SHA-256 signatures

GithubProvider.Validate checked only X-Hub-Signature (HMAC-SHA1). GitHub
sends X-Hub-Signature-256 on every delivery and its documentation
says 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. TestValidateRejectsBadSHA256EvenWithValidSHA1
covers exactly that.

2. The computed hash is no longer logged

IsValidPayload did log.Printf("Calculated Hash: %s", hash) on every
delivery. 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.Validate used ==. Go's string comparison returns as soon as
two 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. httpmock 1.0.4 → 1.4.1.

Tests

TestGithubProvider_Validate was entirely commented out, which is why the
implementation carried // TODO: Update implementation and tests. It is now
eleven 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 (the
fork). That is obviously not something to merge — revert go.mod and the five
files' 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.

jbdevprimary and others added 3 commits August 31, 2026 09:15
…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>
@jbdevprimary jbdevprimary changed the title Validate HMAC-SHA256 signatures, stop logging the computed hash, and compare GitLab tokens in constant time Modernise: SHA-256 signatures, constant-time GitLab tokens, no HMAC in logs, Go 1.25 Aug 31, 2026
…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>
@jbdevprimary

Copy link
Copy Markdown
Author

Adding a fourth fix, and it is the most serious one

I built the image and probed it rather than trusting the unit tests, and found
an authentication bypass that the tests do not reach.

proxyRequest ran the ignored/allowed-user filter before signature
validation and answered 200 to anything it decided to ignore. isIgnoredUser
treats an empty committer on the github provider as ignored
(proxy.go, committer == "" && p.provider == GithubName), so an
unsigned request reaching that branch got a 200 from a proxy that had never
authenticated it.

Measured against the built image with GWP_SECRET set and no signature header
at all:

Incoming request from user:
Ignoring request for user:
-> HTTP 200

Nothing is forwarded upstream, which is presumably why it survived — but 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 runs first.

The regression test took two attempts, which is worth mentioning

My first one used the gitlab provider and passed with the bug deliberately
reintroduced
— gitlab has no empty-committer case, so it never reaches that
branch. The github version fails with got 200 want 400 against the old
ordering. Verified by swapping the blocks back, not assumed.

Also in this push

Both signature headers are now optional at the parser. GetHeaderKeys()
conflates "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 GHE, 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. That is an interface change, so it is a breaking one for any
out-of-tree provider; flagging it explicitly.

A self-contained Dockerfile. build/package/Dockerfile expects a binary
already compiled beside it, so it cannot be handed to a platform that builds
from a repository, and Dockerfile.build is Go 1.13 plus packr on a GOPATH
layout. The new root Dockerfile builds from source in one pass — digest-pinned
Go 1.25, CGO off, distroless static-debian12:nonroot runtime with no shell and
no package manager.

Verified against the running image

case result
SHA-256 only, valid 200
SHA-1 only, valid 200
forged SHA-256 + valid SHA-1 400
no signature at all 400
tampered body, valid signature 400
disallowed path 403

The unsigned-request 200 is gone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant