feat(server): configurable CSP frame-src for cross-origin iframes - #250
Conversation
Adds a SecurityConfig.FrameSrc field threaded into both CSP write sites (SecurityHeadersMiddleware on API routes, the inline header set in ServeHTTP for page routes). When the list is non-empty, the emitted CSP gains a frame-src directive of 'self' plus each provided origin verbatim; when empty, CSP is byte-identical to v0.1.15 and cross-origin iframes remain blocked by default-src 'self'. The duplicate inline CSP block is now a single buildCSP() helper so future CSP changes don't have to touch both call sites. Why: the docs site needs to embed a separately-deployed LiveTemplate app (lt-landing-demo.fly.dev) on the landing page. Same-origin reverse-proxy embedding is fundamentally broken for this — the embedded LiveTemplate client auto-derives its WebSocket URL from window.location, which inside a same-origin iframe points back at the docs host (no matching session). Cross-origin iframe lets the client talk to its own deployment. CSP default of default-src 'self' blocks that frame load until frame-src is configured. Test plan - New TestBuildCSP covers nil/empty/single/multi-origin. - New TestSecurityHeadersMiddleware_FrameSrcEmittedFromConfig and ...NoFrameSrcByDefault cover the header path end-to-end. - Full CI-scoped suite (`go test -race -tags=ci -skip='E2E|e2e' ./...`) passes locally. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code ReviewOverviewThis PR adds a Security — Input validation (actionable)
csp += "; frame-src 'self' " + strings.Join(frameSrc, " ")An entry containing security:
frame_src:
- "https://evil.example.com; script-src *"…would produce Recommendation: validate each entry at config load time. A minimal guard that covers all realistic attack vectors: func (c SecurityConfig) Validate() error {
for _, origin := range c.FrameSrc {
if strings.ContainsAny(origin, ";\r\n") {
return fmt.Errorf("security.frame_src entry %q contains an invalid character", origin)
}
if _, err := url.ParseRequestURI(origin); err != nil {
return fmt.Errorf("security.frame_src entry %q is not a valid URL: %w", origin, err)
}
}
return nil
}Call this alongside the existing Performance — Minor
// in Server struct
csp string
// in NewWithConfig
s.csp = buildCSP(cfg.Security.FrameSrc)
// in ServeHTTP
w.Header().Set("Content-Security-Policy", s.csp)This is a minor concern given CSP construction is just string concatenation, but worth aligning with the middleware pattern. Code quality
Test coverageThe test suite is solid:
One gap: no test for an entry containing a Summary
The validation gap is the only thing I'd want addressed before merge — the rest of the implementation is well-reasoned and the PR description clearly explains the trade-offs. |
There was a problem hiding this comment.
Pull request overview
Adds a configurable security.frame_src setting to allow embedding cross-origin iframes while keeping the existing default CSP unchanged when the setting is unset, and centralizes CSP construction in a shared helper.
Changes:
- Introduces
SecurityConfig.FrameSrcintinkerdown.yaml(security.frame_src) for opt-in CSPframe-srcorigins. - Deduplicates CSP header construction into a new
buildCSP()helper used by both the API security middleware and non-API page responses. - Adds unit tests covering
buildCSP()and verifyingframe-srcemission/omission behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| internal/server/server.go | Threads configured frame_src into the non-API response CSP via buildCSP(). |
| internal/server/middleware.go | Updates security middleware to accept frameSrc and introduces buildCSP() helper. |
| internal/server/middleware_test.go | Adds unit tests for buildCSP() and middleware CSP behavior. |
| internal/config/config.go | Adds SecurityConfig and wires it into the top-level config schema. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "connect-src 'self'; " + | ||
| "frame-ancestors 'none'" | ||
| if len(frameSrc) > 0 { | ||
| csp += "; frame-src 'self' " + strings.Join(frameSrc, " ") | ||
| } |
| w.Header().Set("X-Frame-Options", "DENY") | ||
| w.Header().Set("X-Content-Type-Options", "nosniff") | ||
| w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") | ||
| w.Header().Set("Content-Security-Policy", | ||
| "default-src 'self'; "+ | ||
| "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+ | ||
| "style-src 'self' 'unsafe-inline'; "+ | ||
| "img-src 'self' data: https:; "+ | ||
| "font-src 'self' data:; "+ | ||
| "connect-src 'self'; "+ | ||
| "frame-ancestors 'none'") | ||
| w.Header().Set("Content-Security-Policy", buildCSP(s.config.Security.FrameSrc)) |
| csp := w.Header().Get("Content-Security-Policy") | ||
| if strings.Contains(csp, "frame-src") { | ||
| t.Errorf("frame-src should be omitted when not configured, got: %s", csp) | ||
| } |
- Validate SecurityConfig.FrameSrc at config load to reject CSP/header injection vectors (`;`, `\r`, `\n`, spaces, tabs) and obviously malformed entries (no scheme/host). Operators get a clear startup error instead of a silently-broken CSP. - Cache the built CSP on the Server struct so the page-route hot path no longer re-builds the string on every request — matches the pre-built pattern already used in SecurityHeadersMiddleware. - Tests cover semicolon, newline, CR, space, empty, schemeless, and scheme-only entries (10 cases total). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses Copilot review #3 on PR #250: the existing test only asserted that "frame-src" was absent from the default CSP, leaving the rest of the policy free to drift unnoticed. The new TestBuildCSP_ByteIdenticalDefault pins the full string so any future change to the base policy fails the test deliberately, and the canonical value lives in one place that operator-facing docs can reference. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code ReviewPR: feat(server): configurable CSP frame-src for cross-origin iframes What this doesAdds Issues1. In 2. Wildcard origins pass validation undocumented
3.
4. No integration test for
Nits
SecurityThe validation approach is sound — rejecting The existing SummaryThe core implementation is correct and safe. Issues 1 and 3 are the most worth addressing before merge — the redundant |
Code Review — PR #250: Configurable CSP
|
Picks up the actionable items from the latest claude-review and copilot review without re-litigating the deferred ones (HTTPS-only enforcement intentionally not added — http://localhost is a legitimate dev origin and frame_src is already an explicit operator-vetted allowlist). - Add `,` to the rejected character set. Commas can act as header-value separators in some HTTP parsing contexts; cheap defensive guard. - TestLoadRejectsInjectedFrameSrc: closes the loop on the docstring's startup-failure guarantee by feeding a malicious YAML through Load() end-to-end. - TestServeHTTP_PageRouteEmitsConfiguredFrameSrc: covers the second CSP write site (the inline page-route header set in Server.ServeHTTP) so a regression there can't silently strip frame-src while passing the middleware-only tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — feat(server): configurable CSP frame-src for cross-origin iframesOverviewThis PR adds What works well
Issues and suggestionsNotable gap: the embeddee also needs configurable
|
Summary
SecurityConfig.FrameSrc []stringtotinkerdown.yaml(security.frame_src).buildCSP()helper that also dedupes the previously-duplicated CSP literal.frame-src 'self' <origin> ...; when empty, CSP is byte-identical to v0.1.15.Why
The docs site needs to embed a separately-deployed LiveTemplate app (
lt-landing-demo.fly.dev) on the landing page. Same-origin reverse-proxy embedding doesn't work — the LiveTemplate client auto-derives its WebSocket URL fromwindow.location, which inside a same-origin iframe points at the docs host (no matching session). Cross-origin iframe lets the embedded app's client talk to its own deployment, but the current default CSP (default-src 'self') blocks the frame load untilframe-srcis configured.The opt-in gate keeps the safe default (no cross-origin iframes) while letting site authors carefully widen it per-origin.
Test plan
TestBuildCSPcovers nil / empty / single / multi-originTestSecurityHeadersMiddleware_FrameSrcEmittedFromConfigverifies the header pathTestSecurityHeadersMiddleware_NoFrameSrcByDefaultasserts the byte-identical defaultgo test -race -tags=ci -skip='E2E|e2e' ./...green locallysecurity.frame_src: ["https://lt-landing-demo.fly.dev"]in docstinkerdown.yaml, swap landing iframe src to the cross-origin URL, drop the now-unused/demo/counter/proxy route, deploy + verify🤖 Generated with Claude Code