From 16b5c16a664a2f1b20ad824d907294de9cb60c9e Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Fri, 26 Jun 2026 14:43:47 +0200 Subject: [PATCH] fix: CSP error with `response_mode=form_post` --- backend/internal/middleware/csp_middleware.go | 6 +- .../middleware/csp_middleware_test.go | 24 ------- .../internal/oidc/authorization_handler.go | 17 ++++- backend/internal/oidc/form_post.go | 40 ++++++++++++ backend/internal/oidc/form_post_test.go | 55 ++++++++++++++++ backend/internal/oidc/provider.go | 1 + backend/internal/utils/csp.go | 17 ++++- backend/internal/utils/csp_test.go | 30 +++++++++ tests/specs/oidc.spec.ts | 62 ++++++++++++------- 9 files changed, 195 insertions(+), 57 deletions(-) delete mode 100644 backend/internal/middleware/csp_middleware_test.go create mode 100644 backend/internal/oidc/form_post.go create mode 100644 backend/internal/oidc/form_post_test.go create mode 100644 backend/internal/utils/csp_test.go diff --git a/backend/internal/middleware/csp_middleware.go b/backend/internal/middleware/csp_middleware.go index 2b6f9f88..b3ccfda5 100644 --- a/backend/internal/middleware/csp_middleware.go +++ b/backend/internal/middleware/csp_middleware.go @@ -21,12 +21,8 @@ func (m *CspMiddleware) Add() gin.HandlerFunc { // Generate a random base64 nonce for this request nonce := utils.GenerateCSPNonce() utils.SetCSPNonce(c, nonce) - c.Writer.Header().Set("Content-Security-Policy", BuildCSP(nonce)) + c.Writer.Header().Set("Content-Security-Policy", utils.BuildCSP(nonce)) c.Next() } } - -func BuildCSP(nonce string, formActionExtra ...string) string { - return utils.BuildCSP(nonce, formActionExtra...) -} diff --git a/backend/internal/middleware/csp_middleware_test.go b/backend/internal/middleware/csp_middleware_test.go deleted file mode 100644 index 67f90464..00000000 --- a/backend/internal/middleware/csp_middleware_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package middleware - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestBuildCSP(t *testing.T) { - t.Run("uses self form action by default", func(t *testing.T) { - csp := BuildCSP("test-nonce") - - assert.Contains(t, csp, "form-action 'self';") - assert.Contains(t, csp, "script-src 'self' 'nonce-test-nonce'") - }) - - t.Run("adds validated form action targets", func(t *testing.T) { - csp := BuildCSP("test-nonce", "https://example.com/callback") - - assert.Contains(t, csp, "form-action 'self' https://example.com/callback;") - assert.Equal(t, 1, strings.Count(csp, "form-action")) - }) -} diff --git a/backend/internal/oidc/authorization_handler.go b/backend/internal/oidc/authorization_handler.go index fa036c62..d8a41b27 100644 --- a/backend/internal/oidc/authorization_handler.go +++ b/backend/internal/oidc/authorization_handler.go @@ -99,9 +99,10 @@ func (h *authorizationHandler) authorize(c *gin.Context) { } response.AddParameter("iss", h.baseURL) - if ar.GetResponseMode() == fosite.ResponseModeFormPost && ar.GetRedirectURI() != nil { - c.Header("Content-Security-Policy", utils.BuildCSP(utils.GetCSPNonce(c), ar.GetRedirectURI().String())) - } + + // fosite renders an auto-submitting HTML page for response_mode=form_post, which needs a relaxed CSP + h.relaxCSPForFormPost(c, ar) + h.provider.WriteAuthorizeResponse(ctx, c.Writer, ar, response) } @@ -141,6 +142,8 @@ func (h *authorizationHandler) completeInteraction(c *gin.Context) { func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) { if ar.IsRedirectURIValid() { // Send the error to the client + // fosite delivers the error through response_mode=form_post as well, so it needs the same CSP relaxation as the success path + h.relaxCSPForFormPost(c, ar) h.provider.WriteAuthorizeError(ctx, c.Writer, ar, err) return } @@ -177,3 +180,11 @@ func authorizeRequestParams(requester fosite.AuthorizeRequester) map[string]stri return params } + +// relaxCSPForFormPost loosens the per-request Content-Security-Policy when the response is delivered via response_mode=form_post +func (h *authorizationHandler) relaxCSPForFormPost(c *gin.Context, ar fosite.AuthorizeRequester) { + if ar.GetResponseMode() != fosite.ResponseModeFormPost || ar.GetRedirectURI() == nil { + return + } + c.Header("Content-Security-Policy", utils.BuildFormPostCSP(utils.GetCSPNonce(c), ar.GetRedirectURI().String(), formPostScriptCSPHash)) +} diff --git a/backend/internal/oidc/form_post.go b/backend/internal/oidc/form_post.go new file mode 100644 index 00000000..72ca3068 --- /dev/null +++ b/backend/internal/oidc/form_post.go @@ -0,0 +1,40 @@ +package oidc + +import ( + "crypto/sha256" + "encoding/base64" + "html/template" +) + +// formPostAutoSubmitScript submits the response_mode=form_post page back to the client as soon as it loads +// Pocket ID's Content-Security-Policy forbids 'unsafe-inline' scripts and inline event handlers, which is exactly what fosite's default form_post template relies on (), so that page silently never submits and strands the user on a blank page +// We instead deliver the auto-submit as a regular inline + +`)) + +// cspHashOf returns the CSP hash-source expression ("'sha256-...'") for an inline script body +func cspHashOf(script string) string { + sum := sha256.Sum256([]byte(script)) + return "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'" +} diff --git a/backend/internal/oidc/form_post_test.go b/backend/internal/oidc/form_post_test.go new file mode 100644 index 00000000..2697dce9 --- /dev/null +++ b/backend/internal/oidc/form_post_test.go @@ -0,0 +1,55 @@ +package oidc + +import ( + "crypto/sha256" + "encoding/base64" + "net/url" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func renderFormPost(t *testing.T, params url.Values) string { + t.Helper() + + var buf strings.Builder + err := formPostTemplate.Execute(&buf, struct { + RedirURL string + Parameters url.Values + }{ + RedirURL: "https://client.example.com/callback", + Parameters: params, + }) + require.NoError(t, err) + return buf.String() +} + +// The form_post page must auto-submit without an inline event handler +// fosite's default template uses which our CSP rejects, leaving the user on a blank page with no callback +func TestFormPostTemplateHasNoInlineEventHandler(t *testing.T) { + html := renderFormPost(t, url.Values{"code": {"the-code"}, "state": {"the-state"}}) + + assert.NotContains(t, html, "onload", "form_post page must not rely on an inline onload handler") + assert.Contains(t, html, `action="https://client.example.com/callback"`) + assert.Contains(t, html, `name="code"`) + assert.Contains(t, html, `value="the-code"`) + assert.Contains(t, html, `name="state"`) +} + +// formPostScriptCSPHash must match the inline script the template actually renders +// If the two ever drift, the browser refuses the script and the form never submits, which is the exact regression this guards against +func TestFormPostScriptCSPHashMatchesRenderedScript(t *testing.T) { + html := renderFormPost(t, url.Values{}) + + // Extract the inline script body the browser would compute the hash over + matches := regexp.MustCompile(`(?s)`).FindStringSubmatch(html) + require.Len(t, matches, 2, "rendered form_post page must contain exactly one inline