Conversation
The clickjacking fix correctly added frame-ancestors controls, but custom allowlists still emitted X-Frame-Options: DENY. This keeps SAMEORIGIN for 'self' and DENY for 'none', while letting explicit allowlists rely on CSP alone so supported embeds continue to work. Constraint: Legacy X-Frame-Options cannot express arbitrary allowlists Rejected: Keep DENY for all non-self values | breaks documented IFRAME_ORIGINS allowlists Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep CSP as the source of truth for custom iframe allowlists; do not reintroduce contradictory XFO headers Tested: Added unit coverage for wildcard, self, none, and custom allowlists; local header reproduction for custom origin Not-tested: Full browser iframe E2E across legacy browsers
…ecomputation Gemini's review pointed out two low-risk improvements that strengthen the existing iframe allowlist fix without changing its intent: normalize away empty CSV tokens before building frame-ancestors, and compute the static header set once during app startup instead of rebuilding it on every request. Constraint: IFRAME_ORIGINS is startup configuration, not a dynamic per-request input Rejected: Larger header-construction refactor | not necessary to address the concrete review feedback Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep CSP token normalization aligned with explicit allowlists so empty CSV segments never leak into frame-ancestors Tested: pnpm --dir packages/server test -- src/utils/XSS.test.ts --runInBand Not-tested: Full browser iframe embed verification against a running server
There was a problem hiding this comment.
Code Review
This pull request refactors the iframe security header logic by centralizing it into a new getIframeSecurityHeaders function and improves the parsing of allowed origins by filtering empty strings. Feedback focuses on a security regression where X-Frame-Options: DENY was omitted for custom origins, which could leave legacy browsers unprotected. Additionally, it is recommended to move the header calculation back inside the middleware to ensure robustness against environment changes and to update the test suite to verify the presence of fallback headers.
| export function getIframeSecurityHeaders(): Record<string, string> { | ||
| const allowedOrigins = getAllowedIframeOrigins() | ||
|
|
||
| if (allowedOrigins === '*') { | ||
| return { | ||
| 'Content-Security-Policy': 'frame-ancestors *' | ||
| } | ||
| } | ||
|
|
||
| if (allowedOrigins === "'self'") { | ||
| return { | ||
| 'Content-Security-Policy': `frame-ancestors ${allowedOrigins}`, | ||
| 'X-Frame-Options': 'SAMEORIGIN' | ||
| } | ||
| } | ||
|
|
||
| if (allowedOrigins === "'none'") { | ||
| return { | ||
| 'Content-Security-Policy': `frame-ancestors ${allowedOrigins}`, | ||
| 'X-Frame-Options': 'DENY' | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| 'Content-Security-Policy': `frame-ancestors ${allowedOrigins}` | ||
| } | ||
| } |
There was a problem hiding this comment.
The current implementation omits the X-Frame-Options: DENY header when custom origins are provided. This is a security regression for legacy browsers (like IE11) that do not support the Content-Security-Policy: frame-ancestors directive. To maintain the previous security posture, X-Frame-Options: DENY should be included as a fallback for any configuration other than 'self' (which uses SAMEORIGIN) or * (which allows all).
export function getIframeSecurityHeaders(): Record<string, string> {
const allowedOrigins = getAllowedIframeOrigins()
const headers: Record<string, string> = {
'Content-Security-Policy': 'frame-ancestors ' + allowedOrigins
}
if (allowedOrigins === "'self'") {
headers['X-Frame-Options'] = 'SAMEORIGIN'
} else if (allowedOrigins !== '*') {
headers['X-Frame-Options'] = 'DENY'
}
return headers
}References
- Bypassing user-based authorization is acceptable for API endpoints that are intentionally public (e.g., whitelisted for embedding). This access should not expose sensitive data or allow privilege escalation.
| const iframeSecurityHeaders = getIframeSecurityHeaders() | ||
| this.app.use((req, res, next) => { | ||
| const allowedOrigins = getAllowedIframeOrigins() | ||
| if (allowedOrigins === '*') { | ||
| // Explicitly allow all origins (only when user opts in) | ||
| res.setHeader('Content-Security-Policy', 'frame-ancestors *') | ||
| } else { | ||
| const csp = `frame-ancestors ${allowedOrigins}` | ||
| res.setHeader('Content-Security-Policy', csp) | ||
| // X-Frame-Options for legacy browser support | ||
| if (allowedOrigins === "'self'") { | ||
| res.setHeader('X-Frame-Options', 'SAMEORIGIN') | ||
| } else { | ||
| res.setHeader('X-Frame-Options', 'DENY') | ||
| } | ||
| for (const [headerName, headerValue] of Object.entries(iframeSecurityHeaders)) { | ||
| res.setHeader(headerName, headerValue) | ||
| } | ||
| next() | ||
| }) |
There was a problem hiding this comment.
Moving the call to getIframeSecurityHeaders() outside the middleware means the headers are calculated only once at startup. While this is a minor optimization, it makes the middleware less robust to environment changes (e.g., during testing or in environments where process.env might be updated). For consistency with the previous implementation and other middleware like CORS, it is recommended to call this function inside the middleware.
| const iframeSecurityHeaders = getIframeSecurityHeaders() | |
| this.app.use((req, res, next) => { | |
| const allowedOrigins = getAllowedIframeOrigins() | |
| if (allowedOrigins === '*') { | |
| // Explicitly allow all origins (only when user opts in) | |
| res.setHeader('Content-Security-Policy', 'frame-ancestors *') | |
| } else { | |
| const csp = `frame-ancestors ${allowedOrigins}` | |
| res.setHeader('Content-Security-Policy', csp) | |
| // X-Frame-Options for legacy browser support | |
| if (allowedOrigins === "'self'") { | |
| res.setHeader('X-Frame-Options', 'SAMEORIGIN') | |
| } else { | |
| res.setHeader('X-Frame-Options', 'DENY') | |
| } | |
| for (const [headerName, headerValue] of Object.entries(iframeSecurityHeaders)) { | |
| res.setHeader(headerName, headerValue) | |
| } | |
| next() | |
| }) | |
| this.app.use((req, res, next) => { | |
| const iframeSecurityHeaders = getIframeSecurityHeaders() | |
| for (const [headerName, headerValue] of Object.entries(iframeSecurityHeaders)) { | |
| res.setHeader(headerName, headerValue) | |
| } | |
| next() | |
| }) |
| it('omits X-Frame-Options for custom allowlists', () => { | ||
| process.env.IFRAME_ORIGINS = 'https://embed.example.com,https://admin.example.com' | ||
| expect(getIframeSecurityHeaders()).toEqual({ | ||
| 'Content-Security-Policy': 'frame-ancestors https://embed.example.com https://admin.example.com' | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Update this test case to reflect the inclusion of the X-Frame-Options: DENY header for custom allowlists, ensuring legacy browser protection is verified.
it('includes X-Frame-Options: DENY for custom allowlists', () => {
process.env.IFRAME_ORIGINS = 'https://embed.example.com,https://admin.example.com'
expect(getIframeSecurityHeaders()).toEqual({
'Content-Security-Policy': 'frame-ancestors https://embed.example.com https://admin.example.com',
'X-Frame-Options': 'DENY'
})
})
#6232 to verify the changes in a real environment.