fix: web security hardening (webhook, atom feed, OIDC nonce+PKCE, logout, redirect)#80
Merged
Merged
Conversation
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
5 tasks
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.
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
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/webhooknow requiresX-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/feednow prefersAuthorization: Bearerfor 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
nonceclaim matches (constant time) and sends thecode_verifieron the token exchange. Closes two standard OAuth 2.1 replay classes:Implementation:
internal/auth/oidc.go:GenerateOIDCNonce,GeneratePKCEVerifier,PKCEChallengeFromVerifierAuthURL(state, nonce, pkceChallenge)andExchange(ctx, code, pkceVerifier, expectedNonce)apiOIDCLogingenerates all three secrets and stores them in short-lived HttpOnly cookiesapiOIDCCallbackclears all three cookies on every exit path via deferred cleanupI. Logout POST-only + CSRF
POST /logoutis now wrapped by theauthed()middleware so the existing double-submit CSRF token on HTML form submissions is validated.GET /logouthas been removed — state-changing operations should never be GET. The HTML templates already emitcsrf_tokenform 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 theerr.Error()variant with a fixed/login?error=sso_failedslug. The full error detail is logged server-side vias.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
fe2b039fix: tighten webhook, logout, and OIDC error redirect auth surfaces (A + I + J)d5ae90afix: atom feed accepts Authorization header and omits token from self-link (B)65d040fdocs: correct CSP comment about unsafe-inline requirement (documentation)c7dd363fix: add OIDC nonce and PKCE to login flow (D)6daaf9ddocs: add [Unreleased] section with PR 2 security fixesTest plan
go build ./...cleango vet ./...cleango test ./...— all 28 packages pass, 408 auth+web tests + 30 new nonce/PKCE testsX-Webhook-Secretworks,?secret=...returns 401Known scope decisions
unsafe-inline) is documented but not removed in this PR. Audit showed ~160 inlineonclick=handlers and ~180 inlinestyle=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./metricsexposure) was downgraded to Low after auditinginternal/metrics/metrics.go. All emissions are aggregate counts/histograms with no PII, container names, or image digests. Theregistrylabel onsentinel_registry_errors_totalis 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 theX-Webhook-Secretheader before upgrading.