Skip to content

Commit 311a3f4

Browse files
authored
Fix clickjacking (#6185)
* fix(server): clickjacking * fix(XSS.ts): handle CSV and null values in getAllowedIframeOrigins
1 parent 4114b3b commit 311a3f4

3 files changed

Lines changed: 170 additions & 6 deletions

File tree

packages/server/src/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,13 +189,20 @@ export class App {
189189
// Allow embedding from specified domains.
190190
this.app.use((req, res, next) => {
191191
const allowedOrigins = getAllowedIframeOrigins()
192-
if (allowedOrigins == '*') {
193-
next()
192+
if (allowedOrigins === '*') {
193+
// Explicitly allow all origins (only when user opts in)
194+
res.setHeader('Content-Security-Policy', 'frame-ancestors *')
194195
} else {
195196
const csp = `frame-ancestors ${allowedOrigins}`
196197
res.setHeader('Content-Security-Policy', csp)
197-
next()
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+
}
198204
}
205+
next()
199206
})
200207

201208
// Switch off the default 'X-Powered-By: Express' header
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// At the top of XSS.test.ts, before importing getAllowedIframeOrigins
2+
jest.mock('./domainValidation', () => ({
3+
extractChatflowId: jest.fn(),
4+
isPublicChatflowRequest: jest.fn(),
5+
isTTSGenerateRequest: jest.fn(),
6+
validateChatflowDomain: jest.fn()
7+
}))
8+
9+
import { getAllowedIframeOrigins } from './XSS'
10+
11+
describe('getAllowedIframeOrigins', () => {
12+
const originalEnv = process.env.IFRAME_ORIGINS
13+
14+
afterEach(() => {
15+
// Restore original environment variable after each test
16+
if (originalEnv !== undefined) {
17+
process.env.IFRAME_ORIGINS = originalEnv
18+
} else {
19+
delete process.env.IFRAME_ORIGINS
20+
}
21+
})
22+
23+
describe('default behavior', () => {
24+
it("should return 'self' when IFRAME_ORIGINS is not set", () => {
25+
delete process.env.IFRAME_ORIGINS
26+
expect(getAllowedIframeOrigins()).toBe("'self'")
27+
})
28+
29+
it("should return 'self' when IFRAME_ORIGINS is empty string", () => {
30+
process.env.IFRAME_ORIGINS = ''
31+
expect(getAllowedIframeOrigins()).toBe("'self'")
32+
})
33+
34+
it("should return 'self' when IFRAME_ORIGINS is only whitespace", () => {
35+
process.env.IFRAME_ORIGINS = ' '
36+
expect(getAllowedIframeOrigins()).toBe("'self'")
37+
})
38+
})
39+
40+
describe('CSP special values', () => {
41+
it("should handle 'self' value", () => {
42+
process.env.IFRAME_ORIGINS = "'self'"
43+
expect(getAllowedIframeOrigins()).toBe("'self'")
44+
})
45+
46+
it("should handle 'none' value", () => {
47+
process.env.IFRAME_ORIGINS = "'none'"
48+
expect(getAllowedIframeOrigins()).toBe("'none'")
49+
})
50+
51+
it('should handle wildcard * value', () => {
52+
process.env.IFRAME_ORIGINS = '*'
53+
expect(getAllowedIframeOrigins()).toBe('*')
54+
})
55+
})
56+
57+
describe('single domain', () => {
58+
it('should handle a single FQDN', () => {
59+
process.env.IFRAME_ORIGINS = 'https://example.com'
60+
expect(getAllowedIframeOrigins()).toBe('https://example.com')
61+
})
62+
63+
it('should trim whitespace from single domain', () => {
64+
process.env.IFRAME_ORIGINS = ' https://example.com '
65+
expect(getAllowedIframeOrigins()).toBe('https://example.com')
66+
})
67+
})
68+
69+
describe('multiple domains', () => {
70+
it('should convert comma-separated domains to space-separated', () => {
71+
process.env.IFRAME_ORIGINS = 'https://domain1.com,https://domain2.com'
72+
expect(getAllowedIframeOrigins()).toBe('https://domain1.com https://domain2.com')
73+
})
74+
75+
it('should handle multiple domains with spaces', () => {
76+
process.env.IFRAME_ORIGINS = 'https://domain1.com, https://domain2.com, https://domain3.com'
77+
expect(getAllowedIframeOrigins()).toBe('https://domain1.com https://domain2.com https://domain3.com')
78+
})
79+
80+
it('should trim individual domains in comma-separated list', () => {
81+
process.env.IFRAME_ORIGINS = ' https://app.com , https://admin.com '
82+
expect(getAllowedIframeOrigins()).toBe('https://app.com https://admin.com')
83+
})
84+
})
85+
86+
describe('edge cases', () => {
87+
it('should handle domains with ports', () => {
88+
process.env.IFRAME_ORIGINS = 'https://localhost:3000,https://localhost:4000'
89+
expect(getAllowedIframeOrigins()).toBe('https://localhost:3000 https://localhost:4000')
90+
})
91+
92+
it('should handle domains with paths (though not typical for CSP)', () => {
93+
process.env.IFRAME_ORIGINS = 'https://example.com/path1,https://example.com/path2'
94+
expect(getAllowedIframeOrigins()).toBe('https://example.com/path1 https://example.com/path2')
95+
})
96+
97+
it('should handle mixed protocols', () => {
98+
process.env.IFRAME_ORIGINS = 'http://example.com,https://secure.com'
99+
expect(getAllowedIframeOrigins()).toBe('http://example.com https://secure.com')
100+
})
101+
102+
it('should handle trailing comma', () => {
103+
process.env.IFRAME_ORIGINS = 'https://example.com,'
104+
expect(getAllowedIframeOrigins()).toBe('https://example.com ')
105+
})
106+
107+
it('should handle leading comma', () => {
108+
process.env.IFRAME_ORIGINS = ',https://example.com'
109+
expect(getAllowedIframeOrigins()).toBe(' https://example.com')
110+
})
111+
112+
it('should handle multiple consecutive commas', () => {
113+
process.env.IFRAME_ORIGINS = 'https://domain1.com,,https://domain2.com'
114+
expect(getAllowedIframeOrigins()).toBe('https://domain1.com https://domain2.com')
115+
})
116+
})
117+
118+
describe('real-world scenarios', () => {
119+
it('should handle typical production configuration', () => {
120+
process.env.IFRAME_ORIGINS = 'https://app.example.com,https://admin.example.com,https://dashboard.example.com'
121+
expect(getAllowedIframeOrigins()).toBe('https://app.example.com https://admin.example.com https://dashboard.example.com')
122+
})
123+
124+
it('should handle development configuration with localhost', () => {
125+
process.env.IFRAME_ORIGINS = 'http://localhost:3000,http://localhost:3001,http://127.0.0.1:3000'
126+
expect(getAllowedIframeOrigins()).toBe('http://localhost:3000 http://localhost:3001 http://127.0.0.1:3000')
127+
})
128+
129+
it("should handle mix of 'self' and domains", () => {
130+
process.env.IFRAME_ORIGINS = "'self',https://trusted.com"
131+
expect(getAllowedIframeOrigins()).toBe("'self' https://trusted.com")
132+
})
133+
})
134+
})

packages/server/src/utils/XSS.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Request, Response, NextFunction } from 'express'
1+
import { NextFunction, Request, Response } from 'express'
22
import sanitizeHtml from 'sanitize-html'
3-
import { extractChatflowId, validateChatflowDomain, isPublicChatflowRequest, isTTSGenerateRequest } from './domainValidation'
3+
import { extractChatflowId, isPublicChatflowRequest, isTTSGenerateRequest, validateChatflowDomain } from './domainValidation'
44

55
export function sanitizeMiddleware(req: Request, res: Response, next: NextFunction): void {
66
// decoding is necessary as the url is encoded by the browser
@@ -87,8 +87,31 @@ export function getCorsOptions(): any {
8787
}
8888
}
8989

90+
/**
91+
* Retrieves and normalizes allowed iframe embedding origins for CSP frame-ancestors directive.
92+
*
93+
* Reads `IFRAME_ORIGINS` environment variable (comma-separated FQDNs) and converts it to
94+
* space-separated format required by Content Security Policy specification.
95+
*
96+
* Input format:
97+
* - Comma-separated: `https://domain1.com,https://domain2.com`
98+
* - Special values: `'self'`, `'none'`, or `*`
99+
* - Default: `'self'` (same-origin only)
100+
*
101+
* Output examples:
102+
* - `https://app.com,https://admin.com` → `https://app.com https://admin.com`
103+
* - `'self'` → `'self'`
104+
* - `*` → `*`
105+
*
106+
* @returns Space-separated string for CSP frame-ancestors directive
107+
*/
90108
export function getAllowedIframeOrigins(): string {
91109
// Expects FQDN separated by commas, otherwise nothing or * for all.
92110
// Also CSP allowed values: self or none
93-
return process.env.IFRAME_ORIGINS ?? '*'
111+
const origins = (process.env.IFRAME_ORIGINS?.trim() || undefined) ?? "'self'"
112+
// Convert CSV to space-separated for CSP frame-ancestors directive
113+
return origins
114+
.split(',')
115+
.map((s) => s.trim())
116+
.join(' ')
94117
}

0 commit comments

Comments
 (0)