mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-24 20:09:03 +02:00
fix: CSP error with response_mode=form_post
This commit is contained in:
@@ -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"))
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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[:]) + "'"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
|
||||
RedirectURIMatcher: matchRedirectURI,
|
||||
EnforcePKCEForPublicClients: true,
|
||||
EnablePKCEPlainChallengeMethod: true,
|
||||
FormPostHTMLTemplate: formPostTemplate,
|
||||
RefreshTokenScopes: []string{},
|
||||
GlobalSecret: secret,
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user