fix: CSP error with response_mode=form_post

This commit is contained in:
Elias Schneider
2026-06-26 14:51:56 +02:00
parent 2ed703540d
commit 16b5c16a66
9 changed files with 195 additions and 57 deletions
@@ -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...)
}
@@ -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"))
})
}
+14 -3
View File
@@ -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))
}
+40
View File
@@ -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 (<body onload="...">), so that page silently never submits and strands the user on a blank page
// We instead deliver the auto-submit as a regular inline <script> element and allow-list exactly this body via its SHA-256 hash (formPostScriptCSPHash); the script body and the hash must stay byte-for-byte identical, which form_post_test.go enforces
const formPostAutoSubmitScript = `document.forms[0].submit()`
// formPostScriptCSPHash is the CSP script-src source that allow-lists formPostAutoSubmitScript, e.g. "'sha256-...'"
var formPostScriptCSPHash = cspHashOf(formPostAutoSubmitScript)
// formPostTemplate replaces fosite's DefaultFormPostTemplate
// It behaves identically except the auto-submit runs from an allow-listed <script> element instead of a CSP-blocked inline onload handler, and a <noscript> button lets the user continue if scripts are disabled
var formPostTemplate = template.Must(template.New("form_post").Parse(
`<!DOCTYPE html>
<html>
<head><title>Submit This Form</title></head>
<body>
<form method="post" action="{{ .RedirURL }}">
{{- range $key, $values := .Parameters }}
{{- range $value := $values }}
<input type="hidden" name="{{ $key }}" value="{{ $value }}"/>
{{- end }}
{{- end }}
<noscript><button type="submit">Continue</button></noscript>
</form>
<script>` + formPostAutoSubmitScript + `</script>
</body>
</html>`))
// 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[:]) + "'"
}
+55
View File
@@ -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 <body onload="..."> 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)<script>(.*?)</script>`).FindStringSubmatch(html)
require.Len(t, matches, 2, "rendered form_post page must contain exactly one inline <script> block")
scriptBody := matches[1]
sum := sha256.Sum256([]byte(scriptBody))
want := "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'"
assert.Equal(t, want, formPostScriptCSPHash)
}
+1
View File
@@ -49,6 +49,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
RedirectURIMatcher: matchRedirectURI,
EnforcePKCEForPublicClients: true,
EnablePKCEPlainChallengeMethod: true,
FormPostHTMLTemplate: formPostTemplate,
RefreshTokenScopes: []string{},
GlobalSecret: secret,
}
+16 -1
View File
@@ -26,13 +26,28 @@ func SetCSPNonce(c *gin.Context, nonce string) {
c.Set(cspNonceContextKey, nonce)
}
func BuildCSP(nonce string, formActionExtra ...string) string {
func BuildCSP(nonce string) string {
return buildCSP(nonce, nil, nil)
}
// BuildFormPostCSP builds the Content-Security-Policy for an OIDC response_mode=form_post page
func BuildFormPostCSP(nonce, redirectURI, scriptHash string) string {
return buildCSP(nonce, []string{redirectURI}, []string{scriptHash})
}
func buildCSP(nonce string, formActionExtra, scriptSrcExtra []string) string {
formAction := "'self'"
scriptSrc := "script-src 'self'"
if nonce != "" {
scriptSrc += " 'nonce-" + nonce + "'"
}
for _, extra := range scriptSrcExtra {
if extra != "" {
scriptSrc += " " + extra
}
}
if len(formActionExtra) > 0 {
b := strings.Builder{}
+30
View File
@@ -0,0 +1,30 @@
package utils
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildCSP(t *testing.T) {
csp := BuildCSP("test-nonce")
assert.Contains(t, csp, "form-action 'self';")
assert.Contains(t, csp, "script-src 'self' 'nonce-test-nonce'")
}
func TestBuildFormPostCSP(t *testing.T) {
csp := BuildFormPostCSP("test-nonce", "https://client.example.com/callback", "'sha256-abc123'")
// The client's redirect URI must be an allowed POST target
assert.Contains(t, csp, "form-action 'self' https://client.example.com/callback;")
// The single auto-submit script is allow-listed by hash alongside self and the nonce
assert.Contains(t, csp, "script-src 'self' 'nonce-test-nonce' 'sha256-abc123'")
// Inline scripts in general must stay forbidden in script-src
_, scriptSrc, found := strings.Cut(csp, "script-src")
assert.True(t, found, "csp must contain a script-src directive")
assert.NotContains(t, scriptSrc, "unsafe-inline")
}
+38 -24
View File
@@ -838,9 +838,10 @@ test('Authorize existing client while not signed in with response_mode=form_post
await page.goto(`/authorize?${urlParams.toString()}`);
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Sign in' }).click();
await expectFormPostResponse(page, oidcClient.callbackUrl);
await expectFormPostCallback(page, oidcClient.callbackUrl, () =>
page.getByRole('button', { name: 'Sign in' }).click()
);
});
test('Authorize existing client with response_mode=form_post', async ({ page }) => {
@@ -848,9 +849,9 @@ test('Authorize existing client with response_mode=form_post', async ({ page })
const urlParams = createUrlParams(oidcClient);
urlParams.set('response_mode', 'form_post');
await page.goto(`/authorize?${urlParams.toString()}`);
await expectFormPostResponse(page, oidcClient.callbackUrl);
await expectFormPostCallback(page, oidcClient.callbackUrl, () =>
page.goto(`/authorize?${urlParams.toString()}`)
);
});
test('Authorize existing client with response_mode=fragment', async ({ page }) => {
@@ -869,27 +870,37 @@ test('Authorize existing client with response_mode=fragment', async ({ page }) =
expect(fragmentParams.get('iss')).toBeTruthy();
});
async function expectFormPostResponse(
async function expectFormPostCallback(
page: Page,
callbackUrl: string,
action: () => Promise<unknown>,
expectedState = 'nXx-6Qr-owc1SHBa'
): Promise<URLSearchParams> {
const form = page.locator('form[method="post"]');
await expect(form).toHaveAttribute('action', callbackUrl);
const formData = new URLSearchParams(
await form.locator('input[type="hidden"]').evaluateAll((inputs) => {
const params = new URLSearchParams();
for (const input of inputs) {
params.append(input.name, input.value);
}
return params.toString();
})
);
const isCallbackURL = callbackURLMatcher(callbackUrl);
const callbackRouteMatcher = await routeCallbackPage(page, callbackUrl);
expect(formData.get('code')).toBeTruthy();
expect(formData.get('state')).toBe(expectedState);
expect(formData.get('iss')).toBeTruthy();
return formData;
try {
const requestPromise = page.waitForRequest(
(request) => request.method() === 'POST' && isCallbackURL(new URL(request.url())),
{ timeout: 5000 }
);
const actionPromise = action().then(
() => undefined,
(error) => error
);
const request = await requestPromise;
await actionPromise;
const formData = new URLSearchParams(request.postData() ?? '');
expect(formData.get('code')).toBeTruthy();
expect(formData.get('state')).toBe(expectedState);
expect(formData.get('iss')).toBeTruthy();
return formData;
} finally {
if (!page.isClosed()) {
await page.unroute(callbackRouteMatcher).catch(() => {});
}
}
}
test.describe('OIDC prompt parameter', () => {
@@ -1269,9 +1280,12 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
request_uri: parResult.request_uri!
});
await page.goto(`/authorize?${urlParams.toString()}`);
const formData = await expectFormPostResponse(page, client.callbackUrl, state);
const formData = await expectFormPostCallback(
page,
client.callbackUrl,
() => page.goto(`/authorize?${urlParams.toString()}`),
state
);
expect(formData.get('code')).toBeTruthy();
expect(formData.get('state')).toBe(state);
});