Skip to content

fix: web security hardening (webhook, atom feed, OIDC nonce+PKCE, logout, redirect)#80

Merged
Will-Luck merged 5 commits into
mainfrom
security/web-hardening-for-github
Apr 9, 2026
Merged

fix: web security hardening (webhook, atom feed, OIDC nonce+PKCE, logout, redirect)#80
Will-Luck merged 5 commits into
mainfrom
security/web-hardening-for-github

Conversation

@Will-Luck

Copy link
Copy Markdown
Owner

Summary

Five web-layer security fixes landing as a focused 5-commit PR. This is the GitHub mirror of the homelab Gitea PR #74 which passed CI and smoke tests.

Findings fixed (all originally tracked on the internal Gitea issue tracker)

A. Webhook secret header-only

POST /api/webhook now requires X-Webhook-Secret; the query-string fallback (?secret=...) has been removed. Proxies and access logs record URLs verbatim, leaking the secret into log aggregators and browser history. Webhook clients that previously used ?secret= must migrate to the header. Breaking change for existing webhook integrations using query-string auth.

B. Atom feed token-safe

GET /api/history/feed now prefers Authorization: Bearer for credentials. The legacy ?token= query parameter still works for feed readers that cannot set headers. The rendered feed's <link rel="self"> is now a token-free URL so cached feed bodies do not leak credentials to other subscribers.

D. OIDC nonce + PKCE

The OIDC authorization request now includes a 32-byte nonce and an S256 PKCE challenge. The callback verifies the ID token's nonce claim matches (constant time) and sends the code_verifier on the token exchange. Closes two standard OAuth 2.1 replay classes:

  • ID token replay across sessions — a captured valid ID token from one session could previously be replayed into another session on the same IdP.
  • Code interception — an attacker who intercepted the authorization code (via a malicious redirect registrar, browser history leak on a shared machine, or intermediate proxy logging) could exchange it for tokens.

Implementation:

  • New helpers in internal/auth/oidc.go: GenerateOIDCNonce, GeneratePKCEVerifier, PKCEChallengeFromVerifier
  • Signature changes: AuthURL(state, nonce, pkceChallenge) and Exchange(ctx, code, pkceVerifier, expectedNonce)
  • apiOIDCLogin generates all three secrets and stores them in short-lived HttpOnly cookies
  • apiOIDCCallback clears all three cookies on every exit path via deferred cleanup
  • 30 new unit tests including the RFC 7636 Appendix B test vector

I. Logout POST-only + CSRF

POST /logout is now wrapped by the authed() middleware so the existing double-submit CSRF token on HTML form submissions is validated. GET /logout has been removed — state-changing operations should never be GET. The HTML templates already emit csrf_token form fields inside <form action="/logout">, so this is a pure middleware-layer fix with no frontend changes. Impact of the previous bug was limited (attacker could force a sign-out via <img src="/logout">), but DoS-class nuisances are still worth fixing.

J. OIDC callback error redirect

Replaced url.QueryEscape("SSO login failed: " + errParam) and the err.Error() variant with a fixed /login?error=sso_failed slug. The full error detail is logged server-side via s.deps.Log.Warn. The previous code reflected attacker-controlled IdP error strings into the login page — a reflected-XSS vector if the login template ever rendered {{.Error}} in an unsafe context.

Commits

  1. fe2b039 fix: tighten webhook, logout, and OIDC error redirect auth surfaces (A + I + J)
  2. d5ae90a fix: atom feed accepts Authorization header and omits token from self-link (B)
  3. 65d040f docs: correct CSP comment about unsafe-inline requirement (documentation)
  4. c7dd363 fix: add OIDC nonce and PKCE to login flow (D)
  5. 6daaf9d docs: add [Unreleased] section with PR 2 security fixes

Test plan

  • go build ./... clean
  • go vet ./... clean
  • go test ./... — all 28 packages pass, 408 auth+web tests + 30 new nonce/PKCE tests
  • RFC 7636 Appendix B PKCE test vector matches
  • Isolated smoke test on dev machine — binary boots clean, routes register, templates parse
  • GitHub Actions CI green
  • Manual OIDC login test against a real IdP (planned post-merge against homelab Authentik)
  • Manual webhook test — X-Webhook-Secret works, ?secret=... returns 401
  • Manual logout test — POST with CSRF works, GET returns 404

Known scope decisions

  • Finding H (CSP unsafe-inline) is documented but not removed in this PR. Audit showed ~160 inline onclick= handlers and ~180 inline style= attributes across 15 templates — the full fix is a large mechanical refactor (delegated event listeners, CSS classes) that deserves its own PR. Filed as a follow-up on the internal tracker.
  • Finding C (/metrics exposure) was downgraded to Low after auditing internal/metrics/metrics.go. All emissions are aggregate counts/histograms with no PII, container names, or image digests. The registry label on sentinel_registry_errors_total is the most identifying field and still not sensitive. No auth gate added.

Backwards compatibility note

The webhook query-string secret removal is the only breaking change. If any webhook clients are currently using ?secret=<val>, they must switch to the X-Webhook-Secret header before upgrading.

web-flow added 5 commits April 9, 2026 23:37
Three independent web-layer hardening fixes from issue #68:

* Webhook (finding A): Remove query-string secret path. The secret
  must now be supplied in the X-Webhook-Secret header. The previous
  code accepted ?secret=<val> as a fallback, which leaked the secret
  into nginx access logs, Cloudflare tunnel logs, and browser history
  because proxies record request URLs verbatim. Header-only auth is
  the standard webhook pattern.

* Logout (finding I): Wrap /logout under authed() middleware so the
  CSRF double-submit check applies, and drop the GET /logout route
  entirely. Previously both GET and POST /logout were registered in
  the public-routes block with no CSRF protection, letting an
  attacker force a sign-out with <img src="/logout"> or a
  cross-site POST. The HTML templates already emit a csrf_token
  form field from inside <form action="/logout">, and ValidateCSRF
  honours FormValue("csrf_token") as a fallback, so this is a pure
  middleware-layer fix with no frontend changes.

* OIDC error redirect (finding J): Replace
  url.QueryEscape("SSO login failed: " + errParam) and the
  err.Error() variant with a fixed "/login?error=sso_failed" slug.
  Server-side logs retain the full error detail via
  s.deps.Log.Warn. The previous code reflected attacker-controlled
  IdP error strings into the login page, which would have been a
  reflected-XSS vector if the login template ever rendered {{.Error}}
  in an unsafe context.

Verified: go build clean, 408 web+auth tests passing.

Refs: #68
…-link

Finding B from issue #68.

Before: apiHistoryFeed authenticated only via ?token=<bearer>, then
echoed the same token back in the <link rel="self" href="?token=..."/>
element of the rendered feed. Feed readers hitting the self-link sent
the token in the Referer header, and aggregators caching the feed
body served the token to every subscriber.

After:
1. Authorization: Bearer <token> is the primary credential path (not
   logged by reverse proxies, CDNs, or browser history).
2. ?token= query parameter remains accepted as a fallback for legacy
   feed readers that cannot set headers.
3. The self-link is a token-free /api/history/feed, so cached feed
   bodies carry no credentials.

Refs: #68
The existing comment attributed the 'unsafe-inline' policy to htmx, but
grep -rn "hx-on" internal/web/static/ returns zero matches. The actual
reason is ~160 inline onclick= handlers and ~180 inline style=
attributes across 15 templates (audit detail in issue #72).

Update the comment to describe the real constraint and reference the
follow-up issue that tracks the dedicated CSP hardening work. This is
finding H from #68, partially addressed here by documenting why the
policy is what it is. Full removal of unsafe-inline is deferred to #72
so it can land as a focused refactor PR without bloating the security
hardening PR.

Refs: #68 #72
Finding D from issue #68.

Before: apiOIDCLogin called provider.AuthURL(state) which only sent
a state parameter. apiOIDCCallback called provider.Exchange(ctx, code)
with no code_verifier and no nonce check after verifier.Verify. This
left the OIDC flow vulnerable to two separate replay classes:

1. Missing nonce: the ID token was not bound to the originating login
   attempt, so an attacker who captured a valid ID token from one
   session could replay it into another session on the same IdP.
2. Missing PKCE: if an attacker intercepted the authorization code
   (for example via a malicious redirect_uri registrar, browser
   history leak on a shared machine, or URL logging by an
   intermediate proxy), the code could be exchanged for tokens from
   any client that knew the client_secret — the code had no proof
   of ownership bound to the original request.

Both are standard OAuth 2.1 BCP defences and should have been in
place from day one.

Changes:

internal/auth/oidc.go
  - New GenerateOIDCNonce() returns a 64-char hex nonce (32 random
    bytes) per login attempt.
  - New GeneratePKCEVerifier() returns an RFC 7636-compliant 43-char
    base64url verifier (32 random bytes).
  - New PKCEChallengeFromVerifier() computes base64url(SHA256(v))
    for the S256 code_challenge.
  - AuthURL signature extended from (state) to (state, nonce,
    pkceChallenge). Nonce, code_challenge, and code_challenge_method=S256
    are added via oauth2.SetAuthURLParam.
  - Exchange signature extended from (ctx, code) to
    (ctx, code, pkceVerifier, expectedNonce). The verifier is sent
    with the token exchange; after verifier.Verify, the ID token's
    nonce claim is compared in constant time against expectedNonce
    and the exchange is rejected on mismatch.

internal/web/handlers_auth_mfa.go
  - apiOIDCLogin now generates state + nonce + verifier, computes
    the challenge, and stores all three in short-lived HttpOnly
    cookies (oidc_state, oidc_nonce, oidc_pkce) before redirecting.
    Extracted a setOIDCCookie helper to keep the three cookies
    consistent.
  - apiOIDCCallback reads all three cookies up front, rejects if
    any are missing, and uses a deferred clearOIDCCookie trio so
    the cookies are always wiped on every exit path — success,
    state mismatch, PKCE failure, nonce mismatch, or generic
    error. Single-use is now a control-flow guarantee, not a
    reviewer discipline.
  - Exchange is called with the stored pkceVerifier and nonceCookie
    values.

internal/auth/oidc_test.go
  - New TestGenerateOIDCNonce (length, hex-validity, distinctness).
  - New TestGeneratePKCEVerifier (length, base64url-validity,
    distinctness).
  - New TestPKCEChallengeFromVerifier with the RFC 7636 Appendix B
    test vector, a determinism check, and an explicit
    SHA256+base64url equivalence check.

All 408 auth+web tests pass plus 30 new nonce/PKCE assertions. Full
project go vet clean, go test ./... clean across 28 packages.

End-to-end verification against a real IdP (Authentik on the homelab)
is in the PR test plan and will happen on an isolated smoke-test
container before merge.

Refs: #68
Five security hardening entries from issue #68:
- Webhook header-only auth (finding A)
- Atom feed header auth + token-free self-link (finding B)
- OIDC nonce + PKCE (finding D)
- Logout POST-only and CSRF-protected (finding I)
- OIDC callback error redirect sanitisation (finding J)

Plus a CSP comment correction documenting why 'unsafe-inline' is
still required and pointing at the follow-up CSP-hardening issue
(#72) that tracks the full refactor.

Refs: #68 #72
@Will-Luck Will-Luck merged commit 13a4381 into main Apr 9, 2026
2 checks passed
@Will-Luck Will-Luck deleted the security/web-hardening-for-github branch April 9, 2026 23:46
Will-Luck added a commit that referenced this pull request Apr 17, 2026
…out, redirect) (#80)

* fix: tighten webhook, logout, and OIDC error redirect auth surfaces

Three independent web-layer hardening fixes from issue #68:

* Webhook (finding A): Remove query-string secret path. The secret
  must now be supplied in the X-Webhook-Secret header. The previous
  code accepted ?secret=<val> as a fallback, which leaked the secret
  into nginx access logs, Cloudflare tunnel logs, and browser history
  because proxies record request URLs verbatim. Header-only auth is
  the standard webhook pattern.

* Logout (finding I): Wrap /logout under authed() middleware so the
  CSRF double-submit check applies, and drop the GET /logout route
  entirely. Previously both GET and POST /logout were registered in
  the public-routes block with no CSRF protection, letting an
  attacker force a sign-out with <img src="/logout"> or a
  cross-site POST. The HTML templates already emit a csrf_token
  form field from inside <form action="/logout">, and ValidateCSRF
  honours FormValue("csrf_token") as a fallback, so this is a pure
  middleware-layer fix with no frontend changes.

* OIDC error redirect (finding J): Replace
  url.QueryEscape("SSO login failed: " + errParam) and the
  err.Error() variant with a fixed "/login?error=sso_failed" slug.
  Server-side logs retain the full error detail via
  s.deps.Log.Warn. The previous code reflected attacker-controlled
  IdP error strings into the login page, which would have been a
  reflected-XSS vector if the login template ever rendered {{.Error}}
  in an unsafe context.

Verified: go build clean, 408 web+auth tests passing.

Refs: #68

* fix: atom feed accepts Authorization header and omits token from self-link

Finding B from issue #68.

Before: apiHistoryFeed authenticated only via ?token=<bearer>, then
echoed the same token back in the <link rel="self" href="?token=..."/>
element of the rendered feed. Feed readers hitting the self-link sent
the token in the Referer header, and aggregators caching the feed
body served the token to every subscriber.

After:
1. Authorization: Bearer <token> is the primary credential path (not
   logged by reverse proxies, CDNs, or browser history).
2. ?token= query parameter remains accepted as a fallback for legacy
   feed readers that cannot set headers.
3. The self-link is a token-free /api/history/feed, so cached feed
   bodies carry no credentials.

Refs: #68

* docs: correct CSP comment about unsafe-inline requirement

The existing comment attributed the 'unsafe-inline' policy to htmx, but
grep -rn "hx-on" internal/web/static/ returns zero matches. The actual
reason is ~160 inline onclick= handlers and ~180 inline style=
attributes across 15 templates (audit detail in issue #72).

Update the comment to describe the real constraint and reference the
follow-up issue that tracks the dedicated CSP hardening work. This is
finding H from #68, partially addressed here by documenting why the
policy is what it is. Full removal of unsafe-inline is deferred to #72
so it can land as a focused refactor PR without bloating the security
hardening PR.

Refs: #68 #72

* fix: add OIDC nonce and PKCE to login flow

Finding D from issue #68.

Before: apiOIDCLogin called provider.AuthURL(state) which only sent
a state parameter. apiOIDCCallback called provider.Exchange(ctx, code)
with no code_verifier and no nonce check after verifier.Verify. This
left the OIDC flow vulnerable to two separate replay classes:

1. Missing nonce: the ID token was not bound to the originating login
   attempt, so an attacker who captured a valid ID token from one
   session could replay it into another session on the same IdP.
2. Missing PKCE: if an attacker intercepted the authorization code
   (for example via a malicious redirect_uri registrar, browser
   history leak on a shared machine, or URL logging by an
   intermediate proxy), the code could be exchanged for tokens from
   any client that knew the client_secret — the code had no proof
   of ownership bound to the original request.

Both are standard OAuth 2.1 BCP defences and should have been in
place from day one.

Changes:

internal/auth/oidc.go
  - New GenerateOIDCNonce() returns a 64-char hex nonce (32 random
    bytes) per login attempt.
  - New GeneratePKCEVerifier() returns an RFC 7636-compliant 43-char
    base64url verifier (32 random bytes).
  - New PKCEChallengeFromVerifier() computes base64url(SHA256(v))
    for the S256 code_challenge.
  - AuthURL signature extended from (state) to (state, nonce,
    pkceChallenge). Nonce, code_challenge, and code_challenge_method=S256
    are added via oauth2.SetAuthURLParam.
  - Exchange signature extended from (ctx, code) to
    (ctx, code, pkceVerifier, expectedNonce). The verifier is sent
    with the token exchange; after verifier.Verify, the ID token's
    nonce claim is compared in constant time against expectedNonce
    and the exchange is rejected on mismatch.

internal/web/handlers_auth_mfa.go
  - apiOIDCLogin now generates state + nonce + verifier, computes
    the challenge, and stores all three in short-lived HttpOnly
    cookies (oidc_state, oidc_nonce, oidc_pkce) before redirecting.
    Extracted a setOIDCCookie helper to keep the three cookies
    consistent.
  - apiOIDCCallback reads all three cookies up front, rejects if
    any are missing, and uses a deferred clearOIDCCookie trio so
    the cookies are always wiped on every exit path — success,
    state mismatch, PKCE failure, nonce mismatch, or generic
    error. Single-use is now a control-flow guarantee, not a
    reviewer discipline.
  - Exchange is called with the stored pkceVerifier and nonceCookie
    values.

internal/auth/oidc_test.go
  - New TestGenerateOIDCNonce (length, hex-validity, distinctness).
  - New TestGeneratePKCEVerifier (length, base64url-validity,
    distinctness).
  - New TestPKCEChallengeFromVerifier with the RFC 7636 Appendix B
    test vector, a determinism check, and an explicit
    SHA256+base64url equivalence check.

All 408 auth+web tests pass plus 30 new nonce/PKCE assertions. Full
project go vet clean, go test ./... clean across 28 packages.

End-to-end verification against a real IdP (Authentik on the homelab)
is in the PR test plan and will happen on an isolated smoke-test
container before merge.

Refs: #68

* docs: add [Unreleased] section with PR 2 security fixes

Five security hardening entries from issue #68:
- Webhook header-only auth (finding A)
- Atom feed header auth + token-free self-link (finding B)
- OIDC nonce + PKCE (finding D)
- Logout POST-only and CSRF-protected (finding I)
- OIDC callback error redirect sanitisation (finding J)

Plus a CSP comment correction documenting why 'unsafe-inline' is
still required and pointing at the follow-up CSP-hardening issue
(#72) that tracks the full refactor.

Refs: #68 #72

---------

Co-authored-by: Will Luck <noreply@github.com>
Will-Luck added a commit that referenced this pull request Jun 1, 2026
Replace fixed time.Sleep() waits in TestChannel_* with registry condition polling to fix CI flakiness. See #80.
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.

2 participants