diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 91c88fafc..897f67af4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -33,7 +33,7 @@ import { RateLimiterManager } from './utils/rateLimit' import { SSEStreamer } from './utils/SSEStreamer' import { Telemetry } from './utils/telemetry' import { validateAPIKey } from './utils/validateKey' -import { getAllowedIframeOrigins, getCorsOptions, sanitizeMiddleware } from './utils/XSS' +import { getCorsOptions, getIframeSecurityHeaders, sanitizeMiddleware } from './utils/XSS' declare global { namespace Express { @@ -187,20 +187,10 @@ export class App { this.app.use(cookieParser()) // Allow embedding from specified domains. + 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() }) diff --git a/packages/server/src/utils/XSS.test.ts b/packages/server/src/utils/XSS.test.ts index 2b004f1cd..8fd5cd197 100644 --- a/packages/server/src/utils/XSS.test.ts +++ b/packages/server/src/utils/XSS.test.ts @@ -6,7 +6,7 @@ jest.mock('./domainValidation', () => ({ validateChatflowDomain: jest.fn() })) -import { getAllowedIframeOrigins } from './XSS' +import { getAllowedIframeOrigins, getIframeSecurityHeaders } from './XSS' describe('getAllowedIframeOrigins', () => { const originalEnv = process.env.IFRAME_ORIGINS @@ -101,17 +101,17 @@ describe('getAllowedIframeOrigins', () => { it('should handle trailing comma', () => { process.env.IFRAME_ORIGINS = 'https://example.com,' - expect(getAllowedIframeOrigins()).toBe('https://example.com ') + expect(getAllowedIframeOrigins()).toBe('https://example.com') }) it('should handle leading comma', () => { process.env.IFRAME_ORIGINS = ',https://example.com' - expect(getAllowedIframeOrigins()).toBe(' https://example.com') + expect(getAllowedIframeOrigins()).toBe('https://example.com') }) it('should handle multiple consecutive commas', () => { process.env.IFRAME_ORIGINS = 'https://domain1.com,,https://domain2.com' - expect(getAllowedIframeOrigins()).toBe('https://domain1.com https://domain2.com') + expect(getAllowedIframeOrigins()).toBe('https://domain1.com https://domain2.com') }) }) @@ -132,3 +132,45 @@ describe('getAllowedIframeOrigins', () => { }) }) }) + +describe('getIframeSecurityHeaders', () => { + const originalEnv = process.env.IFRAME_ORIGINS + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.IFRAME_ORIGINS = originalEnv + } else { + delete process.env.IFRAME_ORIGINS + } + }) + + it('returns CSP only for wildcard allowlists', () => { + process.env.IFRAME_ORIGINS = '*' + expect(getIframeSecurityHeaders()).toEqual({ + 'Content-Security-Policy': 'frame-ancestors *' + }) + }) + + it("returns SAMEORIGIN only for 'self'", () => { + process.env.IFRAME_ORIGINS = "'self'" + expect(getIframeSecurityHeaders()).toEqual({ + 'Content-Security-Policy': "frame-ancestors 'self'", + 'X-Frame-Options': 'SAMEORIGIN' + }) + }) + + it("returns DENY for 'none'", () => { + process.env.IFRAME_ORIGINS = "'none'" + expect(getIframeSecurityHeaders()).toEqual({ + 'Content-Security-Policy': "frame-ancestors 'none'", + 'X-Frame-Options': 'DENY' + }) + }) + + 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' + }) + }) +}) diff --git a/packages/server/src/utils/XSS.ts b/packages/server/src/utils/XSS.ts index c6d28ee5b..3cbacd78a 100644 --- a/packages/server/src/utils/XSS.ts +++ b/packages/server/src/utils/XSS.ts @@ -113,5 +113,34 @@ export function getAllowedIframeOrigins(): string { return origins .split(',') .map((s) => s.trim()) + .filter(Boolean) .join(' ') } + +export function getIframeSecurityHeaders(): Record { + 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}` + } +}