Skip to content

Commit 1e21e61

Browse files
shaun0927yau-wd
andauthored
fix(server): preserve custom iframe allowlists in frame headers (#6232)
* Preserve explicit iframe allowlists when hardening frame headers 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 * Avoid malformed iframe CSP values while removing per-request header recomputation 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 --------- Co-authored-by: yau-wd <yau.ong@workday.com>
1 parent 3542a04 commit 1e21e61

3 files changed

Lines changed: 79 additions & 18 deletions

File tree

packages/server/src/index.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { RateLimiterManager } from './utils/rateLimit'
3333
import { SSEStreamer } from './utils/SSEStreamer'
3434
import { Telemetry } from './utils/telemetry'
3535
import { validateAPIKey } from './utils/validateKey'
36-
import { getAllowedIframeOrigins, getCorsOptions, sanitizeMiddleware } from './utils/XSS'
36+
import { getCorsOptions, getIframeSecurityHeaders, sanitizeMiddleware } from './utils/XSS'
3737

3838
declare global {
3939
namespace Express {
@@ -187,20 +187,10 @@ export class App {
187187
this.app.use(cookieParser())
188188

189189
// Allow embedding from specified domains.
190+
const iframeSecurityHeaders = getIframeSecurityHeaders()
190191
this.app.use((req, res, next) => {
191-
const allowedOrigins = getAllowedIframeOrigins()
192-
if (allowedOrigins === '*') {
193-
// Explicitly allow all origins (only when user opts in)
194-
res.setHeader('Content-Security-Policy', 'frame-ancestors *')
195-
} else {
196-
const csp = `frame-ancestors ${allowedOrigins}`
197-
res.setHeader('Content-Security-Policy', csp)
198-
// X-Frame-Options for legacy browser support
199-
if (allowedOrigins === "'self'") {
200-
res.setHeader('X-Frame-Options', 'SAMEORIGIN')
201-
} else {
202-
res.setHeader('X-Frame-Options', 'DENY')
203-
}
192+
for (const [headerName, headerValue] of Object.entries(iframeSecurityHeaders)) {
193+
res.setHeader(headerName, headerValue)
204194
}
205195
next()
206196
})

packages/server/src/utils/XSS.test.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ jest.mock('./domainValidation', () => ({
66
validateChatflowDomain: jest.fn()
77
}))
88

9-
import { getAllowedIframeOrigins } from './XSS'
9+
import { getAllowedIframeOrigins, getIframeSecurityHeaders } from './XSS'
1010

1111
describe('getAllowedIframeOrigins', () => {
1212
const originalEnv = process.env.IFRAME_ORIGINS
@@ -101,17 +101,17 @@ describe('getAllowedIframeOrigins', () => {
101101

102102
it('should handle trailing comma', () => {
103103
process.env.IFRAME_ORIGINS = 'https://example.com,'
104-
expect(getAllowedIframeOrigins()).toBe('https://example.com ')
104+
expect(getAllowedIframeOrigins()).toBe('https://example.com')
105105
})
106106

107107
it('should handle leading comma', () => {
108108
process.env.IFRAME_ORIGINS = ',https://example.com'
109-
expect(getAllowedIframeOrigins()).toBe(' https://example.com')
109+
expect(getAllowedIframeOrigins()).toBe('https://example.com')
110110
})
111111

112112
it('should handle multiple consecutive commas', () => {
113113
process.env.IFRAME_ORIGINS = 'https://domain1.com,,https://domain2.com'
114-
expect(getAllowedIframeOrigins()).toBe('https://domain1.com https://domain2.com')
114+
expect(getAllowedIframeOrigins()).toBe('https://domain1.com https://domain2.com')
115115
})
116116
})
117117

@@ -132,3 +132,45 @@ describe('getAllowedIframeOrigins', () => {
132132
})
133133
})
134134
})
135+
136+
describe('getIframeSecurityHeaders', () => {
137+
const originalEnv = process.env.IFRAME_ORIGINS
138+
139+
afterEach(() => {
140+
if (originalEnv !== undefined) {
141+
process.env.IFRAME_ORIGINS = originalEnv
142+
} else {
143+
delete process.env.IFRAME_ORIGINS
144+
}
145+
})
146+
147+
it('returns CSP only for wildcard allowlists', () => {
148+
process.env.IFRAME_ORIGINS = '*'
149+
expect(getIframeSecurityHeaders()).toEqual({
150+
'Content-Security-Policy': 'frame-ancestors *'
151+
})
152+
})
153+
154+
it("returns SAMEORIGIN only for 'self'", () => {
155+
process.env.IFRAME_ORIGINS = "'self'"
156+
expect(getIframeSecurityHeaders()).toEqual({
157+
'Content-Security-Policy': "frame-ancestors 'self'",
158+
'X-Frame-Options': 'SAMEORIGIN'
159+
})
160+
})
161+
162+
it("returns DENY for 'none'", () => {
163+
process.env.IFRAME_ORIGINS = "'none'"
164+
expect(getIframeSecurityHeaders()).toEqual({
165+
'Content-Security-Policy': "frame-ancestors 'none'",
166+
'X-Frame-Options': 'DENY'
167+
})
168+
})
169+
170+
it('omits X-Frame-Options for custom allowlists', () => {
171+
process.env.IFRAME_ORIGINS = 'https://embed.example.com,https://admin.example.com'
172+
expect(getIframeSecurityHeaders()).toEqual({
173+
'Content-Security-Policy': 'frame-ancestors https://embed.example.com https://admin.example.com'
174+
})
175+
})
176+
})

packages/server/src/utils/XSS.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,5 +113,34 @@ export function getAllowedIframeOrigins(): string {
113113
return origins
114114
.split(',')
115115
.map((s) => s.trim())
116+
.filter(Boolean)
116117
.join(' ')
117118
}
119+
120+
export function getIframeSecurityHeaders(): Record<string, string> {
121+
const allowedOrigins = getAllowedIframeOrigins()
122+
123+
if (allowedOrigins === '*') {
124+
return {
125+
'Content-Security-Policy': 'frame-ancestors *'
126+
}
127+
}
128+
129+
if (allowedOrigins === "'self'") {
130+
return {
131+
'Content-Security-Policy': `frame-ancestors ${allowedOrigins}`,
132+
'X-Frame-Options': 'SAMEORIGIN'
133+
}
134+
}
135+
136+
if (allowedOrigins === "'none'") {
137+
return {
138+
'Content-Security-Policy': `frame-ancestors ${allowedOrigins}`,
139+
'X-Frame-Options': 'DENY'
140+
}
141+
}
142+
143+
return {
144+
'Content-Security-Policy': `frame-ancestors ${allowedOrigins}`
145+
}
146+
}

0 commit comments

Comments
 (0)