create-interview-cockpit 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/client/src/api.ts +39 -0
- package/template/client/src/browserSecurityTemplates.ts +3242 -0
- package/template/client/src/components/BrowserSecurityLabModal.tsx +1510 -0
- package/template/client/src/components/CodeRunnerModal.tsx +406 -55
- package/template/client/src/components/LabsPanel.tsx +123 -12
- package/template/client/src/components/LinkedConvosPicker.tsx +121 -63
- package/template/client/src/components/Sidebar.tsx +113 -0
- package/template/client/src/reactLab.ts +408 -0
- package/template/client/src/store.ts +15 -1
- package/template/client/src/types.ts +2 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/google-drive.ts +2 -0
- package/template/server/src/index.ts +90 -24
- package/template/server/src/storage.ts +2 -0
|
@@ -0,0 +1,1510 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
X,
|
|
4
|
+
Shield,
|
|
5
|
+
ShieldAlert,
|
|
6
|
+
ShieldCheck,
|
|
7
|
+
ShieldOff,
|
|
8
|
+
ChevronRight,
|
|
9
|
+
ChevronDown,
|
|
10
|
+
Play,
|
|
11
|
+
RotateCcw,
|
|
12
|
+
Copy,
|
|
13
|
+
Check,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import { useStore } from "../store";
|
|
16
|
+
|
|
17
|
+
// ═══════════════════════════════════════════════════════ Types
|
|
18
|
+
|
|
19
|
+
type ScenarioId =
|
|
20
|
+
| "csp-basics"
|
|
21
|
+
| "csp-nonce"
|
|
22
|
+
| "csp-report"
|
|
23
|
+
| "sandbox-none"
|
|
24
|
+
| "sandbox-scripts"
|
|
25
|
+
| "sandbox-same-origin"
|
|
26
|
+
| "clickjacking"
|
|
27
|
+
| "xss-reflect"
|
|
28
|
+
| "xss-dom"
|
|
29
|
+
| "cors-simple"
|
|
30
|
+
| "cors-preflight";
|
|
31
|
+
|
|
32
|
+
type TabId = "preview" | "code" | "explanation" | "checklist";
|
|
33
|
+
|
|
34
|
+
interface ScenarioGroup {
|
|
35
|
+
id: string;
|
|
36
|
+
label: string;
|
|
37
|
+
color: string; // tailwind text color
|
|
38
|
+
borderColor: string;
|
|
39
|
+
bgColor: string;
|
|
40
|
+
scenarios: Scenario[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Scenario {
|
|
44
|
+
id: ScenarioId;
|
|
45
|
+
title: string;
|
|
46
|
+
subtitle: string;
|
|
47
|
+
/** The "secure" HTML document rendered as srcdoc inside the iframe. */
|
|
48
|
+
secureDoc: (cfg: Cfg) => string;
|
|
49
|
+
/** The "vulnerable" HTML for comparison — shown side by side. */
|
|
50
|
+
vulnerableDoc: (cfg: Cfg) => string;
|
|
51
|
+
/** HTTP response headers that WOULD be sent by the server. */
|
|
52
|
+
headers: (cfg: Cfg) => Record<string, string>;
|
|
53
|
+
/** Source code that illustrates the server-side implementation. */
|
|
54
|
+
serverCode: (cfg: Cfg) => string;
|
|
55
|
+
explanation: string;
|
|
56
|
+
howItHelps: string[];
|
|
57
|
+
interviewPoints: string[];
|
|
58
|
+
config?: CfgField[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface CfgField {
|
|
62
|
+
key: string;
|
|
63
|
+
label: string;
|
|
64
|
+
type: "toggle" | "select" | "text";
|
|
65
|
+
options?: string[];
|
|
66
|
+
default: string | boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface Cfg {
|
|
70
|
+
[key: string]: string | boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ═══════════════════════════════════════════════════════ Scenarios
|
|
74
|
+
|
|
75
|
+
const SCENARIOS: ScenarioGroup[] = [
|
|
76
|
+
// ─── CSP ────────────────────────────────────────────────
|
|
77
|
+
{
|
|
78
|
+
id: "csp",
|
|
79
|
+
label: "Content Security Policy",
|
|
80
|
+
color: "text-cyan-300",
|
|
81
|
+
borderColor: "border-cyan-500/30",
|
|
82
|
+
bgColor: "bg-cyan-500/10",
|
|
83
|
+
scenarios: [
|
|
84
|
+
{
|
|
85
|
+
id: "csp-basics",
|
|
86
|
+
title: "CSP — Blocking Inline Scripts",
|
|
87
|
+
subtitle: "How CSP stops XSS before it runs",
|
|
88
|
+
config: [
|
|
89
|
+
{
|
|
90
|
+
key: "strictMode",
|
|
91
|
+
label: "Strict CSP (no unsafe-inline)",
|
|
92
|
+
type: "toggle",
|
|
93
|
+
default: true,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
key: "allowEval",
|
|
97
|
+
label: "Allow eval() / unsafe-eval",
|
|
98
|
+
type: "toggle",
|
|
99
|
+
default: false,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
headers: (cfg) => ({
|
|
103
|
+
"Content-Security-Policy": cfg.strictMode
|
|
104
|
+
? `default-src 'self'; script-src 'self'${cfg.allowEval ? " 'unsafe-eval'" : ""}; style-src 'self' 'unsafe-inline'`
|
|
105
|
+
: `default-src 'self'; script-src 'self' 'unsafe-inline'${cfg.allowEval ? " 'unsafe-eval'" : ""}; style-src 'self' 'unsafe-inline'`,
|
|
106
|
+
"X-Content-Type-Options": "nosniff",
|
|
107
|
+
}),
|
|
108
|
+
secureDoc: (cfg) => `<!doctype html>
|
|
109
|
+
<html>
|
|
110
|
+
<head>
|
|
111
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'${cfg.allowEval ? " 'unsafe-eval'" : ""}; style-src 'self' 'unsafe-inline'">
|
|
112
|
+
<style>
|
|
113
|
+
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 1rem; margin: 0; }
|
|
114
|
+
.box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
|
115
|
+
.ok { color: #4ade80; font-weight: 600; }
|
|
116
|
+
.err { color: #f87171; font-weight: 600; }
|
|
117
|
+
.label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
|
118
|
+
</style>
|
|
119
|
+
</head>
|
|
120
|
+
<body>
|
|
121
|
+
<div class="box">
|
|
122
|
+
<div class="label">CSP Header active</div>
|
|
123
|
+
<code style="font-size:0.7rem;color:#94a3b8">script-src 'self'${cfg.allowEval ? " 'unsafe-eval'" : ""}</code>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="box">
|
|
126
|
+
<div class="label">Inline script test</div>
|
|
127
|
+
<span id="inline-result" class="err">BLOCKED ✓ (CSP works)</span>
|
|
128
|
+
<script>
|
|
129
|
+
// This script tag is BLOCKED by CSP — the span text never changes
|
|
130
|
+
document.getElementById('inline-result').textContent = '⚠ EXECUTED (CSP not enforced)';
|
|
131
|
+
document.getElementById('inline-result').className = 'err';
|
|
132
|
+
</script>
|
|
133
|
+
</div>
|
|
134
|
+
${
|
|
135
|
+
cfg.allowEval
|
|
136
|
+
? `<div class="box">
|
|
137
|
+
<div class="label">eval() test (unsafe-eval ALLOWED)</div>
|
|
138
|
+
<span id="eval-result" class="err">waiting...</span>
|
|
139
|
+
<script src="data:text/javascript,
|
|
140
|
+
try {
|
|
141
|
+
eval('document.getElementById(\\'eval-result\\').textContent=\\'eval() ran \\u26a0\\';');
|
|
142
|
+
document.getElementById('eval-result').className='err';
|
|
143
|
+
} catch(e) {
|
|
144
|
+
document.getElementById('eval-result').textContent='eval() blocked ✓';
|
|
145
|
+
}
|
|
146
|
+
"></script>
|
|
147
|
+
</div>`
|
|
148
|
+
: `<div class="box">
|
|
149
|
+
<div class="label">eval() — blocked by policy</div>
|
|
150
|
+
<span class="ok">eval() would be blocked by CSP (unsafe-eval not listed)</span>
|
|
151
|
+
</div>`
|
|
152
|
+
}
|
|
153
|
+
<div class="box">
|
|
154
|
+
<div class="label">External script from untrusted domain</div>
|
|
155
|
+
<span class="ok">BLOCKED ✓ — not in script-src allowlist</span>
|
|
156
|
+
</div>
|
|
157
|
+
</body>
|
|
158
|
+
</html>`,
|
|
159
|
+
vulnerableDoc: (_cfg) => `<!doctype html>
|
|
160
|
+
<html>
|
|
161
|
+
<head>
|
|
162
|
+
<style>
|
|
163
|
+
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 1rem; margin: 0; }
|
|
164
|
+
.box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
|
165
|
+
.ok { color: #4ade80; }
|
|
166
|
+
.err { color: #f87171; font-weight: 600; }
|
|
167
|
+
.label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
|
168
|
+
</style>
|
|
169
|
+
</head>
|
|
170
|
+
<body>
|
|
171
|
+
<div class="box">
|
|
172
|
+
<div class="label">No CSP header</div>
|
|
173
|
+
<span class="err">No Content-Security-Policy set</span>
|
|
174
|
+
</div>
|
|
175
|
+
<div class="box">
|
|
176
|
+
<div class="label">Inline script test</div>
|
|
177
|
+
<span id="inline-result" class="ok">waiting…</span>
|
|
178
|
+
<script>
|
|
179
|
+
document.getElementById('inline-result').textContent = '⚠ EXECUTED — attacker code ran!';
|
|
180
|
+
document.getElementById('inline-result').className = 'err';
|
|
181
|
+
</script>
|
|
182
|
+
</div>
|
|
183
|
+
<div class="box">
|
|
184
|
+
<div class="label">eval() test</div>
|
|
185
|
+
<span id="eval-result" class="ok">waiting…</span>
|
|
186
|
+
<script>
|
|
187
|
+
try {
|
|
188
|
+
eval("document.getElementById('eval-result').textContent = '⚠ eval() ran freely!'");
|
|
189
|
+
document.getElementById('eval-result').className = 'err';
|
|
190
|
+
} catch(e) {}
|
|
191
|
+
</script>
|
|
192
|
+
</div>
|
|
193
|
+
</body>
|
|
194
|
+
</html>`,
|
|
195
|
+
serverCode: (
|
|
196
|
+
cfg,
|
|
197
|
+
) => `// Express.js middleware — set CSP header on every response
|
|
198
|
+
app.use((_req, res, next) => {
|
|
199
|
+
res.setHeader(
|
|
200
|
+
"Content-Security-Policy",
|
|
201
|
+
[
|
|
202
|
+
"default-src 'self'",
|
|
203
|
+
"script-src 'self'${cfg.allowEval ? " 'unsafe-eval'" : ""}", // ${cfg.allowEval ? "⚠ unsafe-eval allowed — avoid if possible" : "no eval() ever"}
|
|
204
|
+
"style-src 'self' 'unsafe-inline'",
|
|
205
|
+
"img-src 'self' data:",
|
|
206
|
+
"font-src 'self'",
|
|
207
|
+
"connect-src 'self'",
|
|
208
|
+
"frame-ancestors 'none'", // blocks clickjacking too
|
|
209
|
+
].join("; "),
|
|
210
|
+
);
|
|
211
|
+
next();
|
|
212
|
+
});
|
|
213
|
+
${!cfg.strictMode ? `\n// ⚠ 'unsafe-inline' is set — inline scripts WILL run.\n// An attacker injecting <script>stealCookies()</script> via XSS will succeed.` : ""}`,
|
|
214
|
+
explanation: `**Content Security Policy (CSP)** is an HTTP response header that tells the browser which sources it's allowed to load and execute resources from.
|
|
215
|
+
|
|
216
|
+
Without CSP, if an attacker injects \`<script>stealCookies()</script>\` via XSS [Cross-Site Scripting — injecting malicious scripts into a page viewed by other users], the browser runs it without question.
|
|
217
|
+
|
|
218
|
+
With \`script-src 'self'\`, the browser refuses to execute:
|
|
219
|
+
- **Inline scripts** (the most common XSS vector)
|
|
220
|
+
- **eval()** and similar string-to-code functions
|
|
221
|
+
- **Scripts loaded from any domain not in your allowlist**
|
|
222
|
+
|
|
223
|
+
The policy is enforced by the browser itself — even if the attacker controls part of your page content, they cannot execute code.`,
|
|
224
|
+
howItHelps: [
|
|
225
|
+
"Stops injected inline <script> tags from running",
|
|
226
|
+
"Prevents eval() / Function() string-to-code attacks",
|
|
227
|
+
"Blocks data exfiltration to attacker-controlled servers (connect-src)",
|
|
228
|
+
"Reduces XSS impact from zero-day library vulnerabilities",
|
|
229
|
+
"Provides defense-in-depth after input validation fails",
|
|
230
|
+
],
|
|
231
|
+
interviewPoints: [
|
|
232
|
+
"'unsafe-inline' negates most of CSP's value — it's a last resort flag",
|
|
233
|
+
"Nonces and hashes let you allow specific inline scripts without 'unsafe-inline'",
|
|
234
|
+
"CSP Level 3 'strict-dynamic' helps SPAs avoid long allowlists",
|
|
235
|
+
"Report-to / report-uri sends violations to a logging endpoint without blocking",
|
|
236
|
+
"frame-ancestors 'none' replaces the older X-Frame-Options header",
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: "csp-nonce",
|
|
241
|
+
title: "CSP Nonces — Allowing Trusted Inline Scripts",
|
|
242
|
+
subtitle: "Selectively permit inline scripts without 'unsafe-inline'",
|
|
243
|
+
config: [
|
|
244
|
+
{
|
|
245
|
+
key: "useNonce",
|
|
246
|
+
label: "Use nonce on <script> tag",
|
|
247
|
+
type: "toggle",
|
|
248
|
+
default: true,
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
headers: (cfg) => ({
|
|
252
|
+
"Content-Security-Policy": `script-src 'nonce-abc123xyz'${cfg.useNonce ? "" : " 'unsafe-inline'"}; default-src 'self'`,
|
|
253
|
+
}),
|
|
254
|
+
secureDoc: (cfg) => `<!doctype html>
|
|
255
|
+
<html>
|
|
256
|
+
<head>
|
|
257
|
+
<meta http-equiv="Content-Security-Policy"
|
|
258
|
+
content="script-src 'nonce-abc123xyz'; default-src 'self'">
|
|
259
|
+
<style>
|
|
260
|
+
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 1rem; margin: 0; }
|
|
261
|
+
.box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
|
262
|
+
.ok { color: #4ade80; font-weight: 600; }
|
|
263
|
+
.err { color: #f87171; font-weight: 600; }
|
|
264
|
+
.label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
|
265
|
+
</style>
|
|
266
|
+
</head>
|
|
267
|
+
<body>
|
|
268
|
+
<div class="box">
|
|
269
|
+
<div class="label">Script WITH correct nonce (nonce="abc123xyz")</div>
|
|
270
|
+
<span id="nonce-ok" class="err">waiting…</span>
|
|
271
|
+
${
|
|
272
|
+
cfg.useNonce
|
|
273
|
+
? `<script nonce="abc123xyz">
|
|
274
|
+
document.getElementById('nonce-ok').textContent = '✓ Ran — nonce matched CSP';
|
|
275
|
+
document.getElementById('nonce-ok').className = 'ok';
|
|
276
|
+
</script>`
|
|
277
|
+
: `<script>
|
|
278
|
+
document.getElementById('nonce-ok').textContent = '⚠ Ran WITHOUT nonce (unsafe mode)';
|
|
279
|
+
document.getElementById('nonce-ok').className = 'err';
|
|
280
|
+
</script>`
|
|
281
|
+
}
|
|
282
|
+
</div>
|
|
283
|
+
<div class="box">
|
|
284
|
+
<div class="label">Injected script WITHOUT nonce (simulated XSS)</div>
|
|
285
|
+
<span id="xss-result" class="ok">BLOCKED ✓ — no nonce, so CSP refuses to run it</span>
|
|
286
|
+
<script>
|
|
287
|
+
// No nonce — this is blocked by the CSP even though it's inline.
|
|
288
|
+
// An attacker who injects this via XSS cannot get the server's nonce.
|
|
289
|
+
document.getElementById('xss-result').textContent = '⚠ XSS script ran!';
|
|
290
|
+
document.getElementById('xss-result').className = 'err';
|
|
291
|
+
</script>
|
|
292
|
+
</div>
|
|
293
|
+
<div class="box">
|
|
294
|
+
<div class="label">Why this works</div>
|
|
295
|
+
<span style="font-size:0.75rem;color:#94a3b8">
|
|
296
|
+
The server generates a new random nonce per request. Even if an attacker
|
|
297
|
+
injects a <script> tag, they cannot guess the nonce — only scripts
|
|
298
|
+
the server intentionally nonces are allowed to run.
|
|
299
|
+
</span>
|
|
300
|
+
</div>
|
|
301
|
+
</body>
|
|
302
|
+
</html>`,
|
|
303
|
+
vulnerableDoc: (_cfg) => `<!doctype html>
|
|
304
|
+
<html>
|
|
305
|
+
<head>
|
|
306
|
+
<style>
|
|
307
|
+
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 1rem; margin: 0; }
|
|
308
|
+
.box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
|
309
|
+
.err { color: #f87171; font-weight: 600; }
|
|
310
|
+
.label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
|
311
|
+
</style>
|
|
312
|
+
</head>
|
|
313
|
+
<body>
|
|
314
|
+
<div class="box">
|
|
315
|
+
<div class="label">No nonce strategy — both scripts run</div>
|
|
316
|
+
<span id="trusted-result" class="err">waiting…</span>
|
|
317
|
+
<script>
|
|
318
|
+
document.getElementById('trusted-result').textContent = '⚠ "Trusted" script ran (expected)';
|
|
319
|
+
</script>
|
|
320
|
+
</div>
|
|
321
|
+
<div class="box">
|
|
322
|
+
<div class="label">Injected attacker script also runs</div>
|
|
323
|
+
<span id="attack-result" class="err">waiting…</span>
|
|
324
|
+
<script>
|
|
325
|
+
document.getElementById('attack-result').textContent = '⚠ XSS script also ran — no way to distinguish!';
|
|
326
|
+
</script>
|
|
327
|
+
</div>
|
|
328
|
+
</body>
|
|
329
|
+
</html>`,
|
|
330
|
+
serverCode: (_cfg) => `import crypto from "node:crypto";
|
|
331
|
+
|
|
332
|
+
// Middleware: generate a fresh cryptographic nonce per request.
|
|
333
|
+
// A nonce [number used once] is a random base64 string generated server-side.
|
|
334
|
+
// The attacker cannot guess it, so injected scripts cannot include it.
|
|
335
|
+
app.use((req, res, next) => {
|
|
336
|
+
// 16 random bytes → 128-bit entropy → attacker cannot guess
|
|
337
|
+
const nonce = crypto.randomBytes(16).toString("base64");
|
|
338
|
+
res.locals.cspNonce = nonce;
|
|
339
|
+
|
|
340
|
+
res.setHeader(
|
|
341
|
+
"Content-Security-Policy",
|
|
342
|
+
\`script-src 'nonce-\${nonce}'; default-src 'self'\`,
|
|
343
|
+
);
|
|
344
|
+
next();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// In your template engine (e.g. EJS, Handlebars, React SSR):
|
|
348
|
+
// <script nonce="<%= nonce %>">/* trusted code */</script>
|
|
349
|
+
//
|
|
350
|
+
// The nonce on the <script> tag must match the one in the CSP header.
|
|
351
|
+
// Because the nonce changes with every page load, an attacker who injects
|
|
352
|
+
// <script>stealCookies()</script> cannot include the current nonce, so the
|
|
353
|
+
// browser refuses to run the injection.`,
|
|
354
|
+
explanation: `A **nonce** [number used once] is a random value generated by the server for each response. It's included in both the CSP header and on every trusted \`<script>\` tag.
|
|
355
|
+
|
|
356
|
+
The browser only executes inline scripts whose nonce attribute matches the one declared in \`script-src 'nonce-ABC'\`.
|
|
357
|
+
|
|
358
|
+
Since the nonce changes with every page load and the attacker cannot predict it, even if they inject a \`<script>\` tag via XSS, they cannot include the valid nonce — so the browser refuses to execute the injection.
|
|
359
|
+
|
|
360
|
+
This is the preferred way to allow inline scripts (e.g., in SSR apps) without resorting to the dangerous \`'unsafe-inline'\` directive.`,
|
|
361
|
+
howItHelps: [
|
|
362
|
+
"Allows legitimate inline scripts (e.g. from SSR templates) without 'unsafe-inline'",
|
|
363
|
+
"Injected XSS scripts cannot include the nonce — they're blocked",
|
|
364
|
+
"Works well with React SSR, Next.js, and Express template engines",
|
|
365
|
+
"CSP Level 3 'strict-dynamic' extends nonce trust to dynamically added scripts",
|
|
366
|
+
"Each page load gets a new nonce — replayed nonces in old responses are invalidated",
|
|
367
|
+
],
|
|
368
|
+
interviewPoints: [
|
|
369
|
+
"Nonce must be cryptographically random (crypto.randomBytes), not sequential",
|
|
370
|
+
"Must generate a NEW nonce per request — never reuse or cache it",
|
|
371
|
+
"Framework CSP middleware (Helmet.js) handles nonce generation automatically",
|
|
372
|
+
"'strict-dynamic' combined with nonce removes the need for a domain allowlist",
|
|
373
|
+
"Hash-based CSP ('sha256-...') is an alternative when content is static",
|
|
374
|
+
],
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
id: "csp-report",
|
|
378
|
+
title: "CSP Report-Only — Monitor Without Breaking",
|
|
379
|
+
subtitle: "Roll out CSP safely using report-only mode first",
|
|
380
|
+
config: [
|
|
381
|
+
{
|
|
382
|
+
key: "reportOnly",
|
|
383
|
+
label: "Report-Only mode (no blocking)",
|
|
384
|
+
type: "toggle",
|
|
385
|
+
default: true,
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
headers: (cfg) => ({
|
|
389
|
+
[cfg.reportOnly
|
|
390
|
+
? "Content-Security-Policy-Report-Only"
|
|
391
|
+
: "Content-Security-Policy"]:
|
|
392
|
+
`default-src 'self'; script-src 'self'; report-uri /csp-violations`,
|
|
393
|
+
}),
|
|
394
|
+
secureDoc: (cfg) => `<!doctype html>
|
|
395
|
+
<html>
|
|
396
|
+
<head>
|
|
397
|
+
<meta http-equiv="${cfg.reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy"}"
|
|
398
|
+
content="default-src 'self'; script-src 'self'; report-uri /csp-violations">
|
|
399
|
+
<style>
|
|
400
|
+
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 1rem; margin: 0; }
|
|
401
|
+
.box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
|
402
|
+
.ok { color: #4ade80; font-weight: 600; }
|
|
403
|
+
.warn { color: #facc15; font-weight: 600; }
|
|
404
|
+
.err { color: #f87171; font-weight: 600; }
|
|
405
|
+
.label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
|
406
|
+
</style>
|
|
407
|
+
</head>
|
|
408
|
+
<body>
|
|
409
|
+
<div class="box">
|
|
410
|
+
<div class="label">Mode: ${cfg.reportOnly ? "Report-Only (no blocking)" : "Enforcing (blocking)"}</div>
|
|
411
|
+
<span class="${cfg.reportOnly ? "warn" : "ok"}">
|
|
412
|
+
${cfg.reportOnly ? "⚠ Violations are REPORTED but scripts still run" : "✓ Violations are BLOCKED"}
|
|
413
|
+
</span>
|
|
414
|
+
</div>
|
|
415
|
+
<div class="box">
|
|
416
|
+
<div class="label">Inline script violation test</div>
|
|
417
|
+
<span id="result" class="${cfg.reportOnly ? "warn" : "ok"}">
|
|
418
|
+
${cfg.reportOnly ? "⚠ Inline script ran — reported to /csp-violations" : "BLOCKED ✓"}
|
|
419
|
+
</span>
|
|
420
|
+
<script>
|
|
421
|
+
// In report-only mode this runs but generates a CSP violation report.
|
|
422
|
+
// In enforcing mode it is blocked entirely.
|
|
423
|
+
document.getElementById('result').textContent =
|
|
424
|
+
'${cfg.reportOnly ? "⚠ Script ran (report-only — not blocked)" : "✓ BLOCKED by enforcing CSP"}';
|
|
425
|
+
</script>
|
|
426
|
+
</div>
|
|
427
|
+
<div class="box">
|
|
428
|
+
<div class="label">Strategy</div>
|
|
429
|
+
<span style="font-size:0.75rem;color:#94a3b8">
|
|
430
|
+
1. Deploy with Report-Only → collect violation data for 1–2 weeks.<br>
|
|
431
|
+
2. Fix all legitimate violations (update allowlists / add nonces).<br>
|
|
432
|
+
3. Switch header to Content-Security-Policy to enforce.
|
|
433
|
+
</span>
|
|
434
|
+
</div>
|
|
435
|
+
</body>
|
|
436
|
+
</html>`,
|
|
437
|
+
vulnerableDoc: (_cfg) => `<!doctype html>
|
|
438
|
+
<html>
|
|
439
|
+
<head>
|
|
440
|
+
<style>
|
|
441
|
+
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 1rem; margin: 0; }
|
|
442
|
+
.box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
|
443
|
+
.err { color: #f87171; font-weight: 600; }
|
|
444
|
+
.label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
|
445
|
+
</style>
|
|
446
|
+
</head>
|
|
447
|
+
<body>
|
|
448
|
+
<div class="box">
|
|
449
|
+
<div class="label">No CSP at all</div>
|
|
450
|
+
<span class="err">Violations never reported — you have no visibility into injection attempts</span>
|
|
451
|
+
</div>
|
|
452
|
+
<div class="box">
|
|
453
|
+
<div class="label">Inline scripts run freely</div>
|
|
454
|
+
<span id="result" class="err">waiting…</span>
|
|
455
|
+
<script>
|
|
456
|
+
document.getElementById('result').textContent = '⚠ Ran — no monitoring, no protection';
|
|
457
|
+
</script>
|
|
458
|
+
</div>
|
|
459
|
+
</body>
|
|
460
|
+
</html>`,
|
|
461
|
+
serverCode: (
|
|
462
|
+
_cfg,
|
|
463
|
+
) => `// Phase 1: Report-Only — NEVER blocks, only sends violation reports to /csp-violations
|
|
464
|
+
app.use((_req, res, next) => {
|
|
465
|
+
res.setHeader(
|
|
466
|
+
"Content-Security-Policy-Report-Only",
|
|
467
|
+
"default-src 'self'; script-src 'self'; report-uri /csp-violations",
|
|
468
|
+
);
|
|
469
|
+
next();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Collect violation reports — the browser POSTs JSON here automatically
|
|
473
|
+
app.post("/csp-violations", (req, res) => {
|
|
474
|
+
const report = req.body["csp-report"];
|
|
475
|
+
console.log("CSP violation:", {
|
|
476
|
+
blocked: report["blocked-uri"],
|
|
477
|
+
directive: report["violated-directive"],
|
|
478
|
+
source: report["source-file"],
|
|
479
|
+
line: report["line-number"],
|
|
480
|
+
});
|
|
481
|
+
res.status(204).end();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Phase 2: After reviewing reports and fixing all legitimate violations,
|
|
485
|
+
// switch to the enforcing header:
|
|
486
|
+
// res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'");`,
|
|
487
|
+
explanation: `**CSP Report-Only** mode lets you test a CSP policy on a production site WITHOUT breaking anything. The browser evaluates the policy and sends JSON violation reports to your \`report-uri\` endpoint — but does not actually block anything.
|
|
488
|
+
|
|
489
|
+
**The rollout strategy:**
|
|
490
|
+
1. Deploy with \`Content-Security-Policy-Report-Only\` and collect violation data for 1–2 weeks
|
|
491
|
+
2. Each violation report tells you exactly what resource was blocked, which directive it violated, and the source file/line number
|
|
492
|
+
3. Fix legitimate violations by adding nonces, updating allowlists, or refactoring inline scripts
|
|
493
|
+
4. Once violations stop (or are all from actual attacks), switch to the enforcing \`Content-Security-Policy\` header
|
|
494
|
+
|
|
495
|
+
This approach eliminates the risk of breaking a live site when introducing CSP.`,
|
|
496
|
+
howItHelps: [
|
|
497
|
+
"Zero-risk way to measure a new CSP policy's impact before enforcing",
|
|
498
|
+
"Reveals third-party scripts, CDNs, and inline code you may have forgotten",
|
|
499
|
+
"Provides real attack detection data in production",
|
|
500
|
+
"Forces teams to audit what scripts actually run on their pages",
|
|
501
|
+
"Gives developers weeks to fix violations before enforcement starts",
|
|
502
|
+
],
|
|
503
|
+
interviewPoints: [
|
|
504
|
+
"You can send BOTH headers simultaneously — enforce a strict core while reporting on a stricter future policy",
|
|
505
|
+
"report-to (Reporting API) is the modern replacement for report-uri",
|
|
506
|
+
"Google's CSP Evaluator tool helps audit policies before deployment",
|
|
507
|
+
"'strict-dynamic' + nonce is the recommended endgame policy for SPAs",
|
|
508
|
+
"CSP violations are a free real-time XSS detection system",
|
|
509
|
+
],
|
|
510
|
+
},
|
|
511
|
+
],
|
|
512
|
+
},
|
|
513
|
+
// ─── Sandboxing ─────────────────────────────────────────
|
|
514
|
+
{
|
|
515
|
+
id: "sandbox",
|
|
516
|
+
label: "iframe Sandboxing",
|
|
517
|
+
color: "text-amber-300",
|
|
518
|
+
borderColor: "border-amber-500/30",
|
|
519
|
+
bgColor: "bg-amber-500/10",
|
|
520
|
+
scenarios: [
|
|
521
|
+
{
|
|
522
|
+
id: "sandbox-none",
|
|
523
|
+
title: "iframe — No Sandbox (Full Trust)",
|
|
524
|
+
subtitle: "An unsandboxed iframe inherits all parent privileges",
|
|
525
|
+
config: [],
|
|
526
|
+
headers: (_cfg) => ({
|
|
527
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
528
|
+
}),
|
|
529
|
+
secureDoc: (_cfg) => `<!doctype html>
|
|
530
|
+
<html>
|
|
531
|
+
<head>
|
|
532
|
+
<style>
|
|
533
|
+
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 1rem; margin: 0; }
|
|
534
|
+
.box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
|
535
|
+
.err { color: #f87171; font-weight: 600; }
|
|
536
|
+
.label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
|
537
|
+
</style>
|
|
538
|
+
</head>
|
|
539
|
+
<body>
|
|
540
|
+
<div class="box">
|
|
541
|
+
<div class="label">Unsandboxed iframe embedded below</div>
|
|
542
|
+
<span class="err">⚠ Content runs scripts, can access parent origin (same-origin only), submit forms, and navigate top window</span>
|
|
543
|
+
</div>
|
|
544
|
+
<iframe
|
|
545
|
+
srcdoc="<script>document.write('I ran! I could navigate parent: ' + (window.parent !== window))</script>"
|
|
546
|
+
style="width:100%;border:1px solid #334155;border-radius:4px;background:#0f172a;color:white;min-height:40px"
|
|
547
|
+
></iframe>
|
|
548
|
+
<div class="box" style="margin-top:0.75rem">
|
|
549
|
+
<div class="label">Risk</div>
|
|
550
|
+
<span style="font-size:0.75rem;color:#94a3b8">
|
|
551
|
+
If this iframe loads user-controlled or third-party content with no sandbox,
|
|
552
|
+
that content can navigate the page, submit forms, access storage (if same-origin),
|
|
553
|
+
and run arbitrary JavaScript.
|
|
554
|
+
</span>
|
|
555
|
+
</div>
|
|
556
|
+
</body>
|
|
557
|
+
</html>`,
|
|
558
|
+
vulnerableDoc: (_cfg) => `<!doctype html>
|
|
559
|
+
<html>
|
|
560
|
+
<head>
|
|
561
|
+
<style>
|
|
562
|
+
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 1rem; margin: 0; }
|
|
563
|
+
.box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
|
564
|
+
.err { color: #f87171; }
|
|
565
|
+
</style>
|
|
566
|
+
</head>
|
|
567
|
+
<body>
|
|
568
|
+
<div class="box">
|
|
569
|
+
<span class="err">Same as "secure" view — without sandbox="" the iframe is fully trusted regardless. Compare using the sandbox scenarios instead.</span>
|
|
570
|
+
</div>
|
|
571
|
+
</body>
|
|
572
|
+
</html>`,
|
|
573
|
+
serverCode: (
|
|
574
|
+
_cfg,
|
|
575
|
+
) => `<!-- HTML — never embed untrusted content without sandbox -->
|
|
576
|
+
|
|
577
|
+
<!-- ❌ Dangerous — iframe gets full trust -->
|
|
578
|
+
<iframe src="https://third-party.example.com/widget"></iframe>
|
|
579
|
+
|
|
580
|
+
<!-- ✅ Sandboxed — scripts, forms, and navigation all blocked by default -->
|
|
581
|
+
<iframe
|
|
582
|
+
sandbox="allow-scripts allow-same-origin"
|
|
583
|
+
src="https://third-party.example.com/widget"
|
|
584
|
+
></iframe>
|
|
585
|
+
|
|
586
|
+
<!-- ✅ Most restrictive — nothing is allowed (display-only) -->
|
|
587
|
+
<iframe sandbox="" src="https://static-preview.example.com"></iframe>`,
|
|
588
|
+
explanation: `Without the \`sandbox\` attribute, an embedded iframe inherits the full capabilities of its origin. If the embedded content is third-party or user-generated, it can:
|
|
589
|
+
- Run arbitrary JavaScript
|
|
590
|
+
- Submit forms (e.g. phishing)
|
|
591
|
+
- Navigate the top-level window
|
|
592
|
+
- Access cookies and localStorage (if same-origin)
|
|
593
|
+
|
|
594
|
+
The \`sandbox\` attribute starts with **zero capabilities** and only grants back what you explicitly list.`,
|
|
595
|
+
howItHelps: [
|
|
596
|
+
"Smallest possible privilege surface for embedded content",
|
|
597
|
+
"Even if embedded content is compromised, it cannot affect the parent page",
|
|
598
|
+
"Prevents phishing via form submission from embedded widgets",
|
|
599
|
+
"Stops embedded content from navigating the user away from your page",
|
|
600
|
+
"Defense-in-depth for micro-frontend architectures",
|
|
601
|
+
],
|
|
602
|
+
interviewPoints: [
|
|
603
|
+
'sandbox="" with no tokens is the most restrictive — no scripts, no storage, no navigation',
|
|
604
|
+
"allow-same-origin + allow-scripts together is dangerous: the iframe can remove the sandbox via JS",
|
|
605
|
+
"Modern micro-frontend architectures use sandbox for cross-team isolation",
|
|
606
|
+
"Permissions Policy restricts powerful APIs (camera, geolocation) similarly",
|
|
607
|
+
"Content embedded in sandboxed iframes is a separate browsing context",
|
|
608
|
+
],
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
id: "sandbox-scripts",
|
|
612
|
+
title: 'sandbox="allow-scripts" — Scripts Without Storage',
|
|
613
|
+
subtitle: "Allow JS execution but block same-origin storage access",
|
|
614
|
+
config: [
|
|
615
|
+
{
|
|
616
|
+
key: "allowSameOrigin",
|
|
617
|
+
label: "Also add allow-same-origin",
|
|
618
|
+
type: "toggle",
|
|
619
|
+
default: false,
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
key: "allowForms",
|
|
623
|
+
label: "Also add allow-forms",
|
|
624
|
+
type: "toggle",
|
|
625
|
+
default: false,
|
|
626
|
+
},
|
|
627
|
+
],
|
|
628
|
+
headers: (_cfg) => ({ "X-Frame-Options": "SAMEORIGIN" }),
|
|
629
|
+
secureDoc: (cfg) => {
|
|
630
|
+
const tokens = [
|
|
631
|
+
"allow-scripts",
|
|
632
|
+
...(cfg.allowSameOrigin ? ["allow-same-origin"] : []),
|
|
633
|
+
...(cfg.allowForms ? ["allow-forms"] : []),
|
|
634
|
+
].join(" ");
|
|
635
|
+
return `<!doctype html>
|
|
636
|
+
<html>
|
|
637
|
+
<head>
|
|
638
|
+
<style>
|
|
639
|
+
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 1rem; margin: 0; }
|
|
640
|
+
.box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
|
641
|
+
.ok { color: #4ade80; font-weight: 600; }
|
|
642
|
+
.err { color: #f87171; font-weight: 600; }
|
|
643
|
+
.warn { color: #facc15; font-weight: 600; }
|
|
644
|
+
.label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
|
645
|
+
</style>
|
|
646
|
+
</head>
|
|
647
|
+
<body>
|
|
648
|
+
<div class="box">
|
|
649
|
+
<div class="label">Active sandbox tokens</div>
|
|
650
|
+
<code style="color:#94a3b8;font-size:0.75rem">sandbox="${tokens}"</code>
|
|
651
|
+
</div>
|
|
652
|
+
<iframe
|
|
653
|
+
sandbox="${tokens}"
|
|
654
|
+
srcdoc='<!doctype html>
|
|
655
|
+
<html><head><style>
|
|
656
|
+
body{font-family:system-ui;background:#1e293b;color:#e2e8f0;padding:0.75rem;margin:0;font-size:0.8rem}
|
|
657
|
+
.ok{color:#4ade80;font-weight:600}.err{color:#f87171;font-weight:600}.label{color:#64748b;font-size:0.65rem;text-transform:uppercase;margin-bottom:0.25rem}
|
|
658
|
+
</style></head><body>
|
|
659
|
+
<div class="label">Script execution</div>
|
|
660
|
+
<span id="s" class="err">waiting</span><script>document.getElementById("s").textContent="✓ JS runs";document.getElementById("s").className="ok"</script>
|
|
661
|
+
<br><br>
|
|
662
|
+
<div class="label">localStorage access ${cfg.allowSameOrigin ? "(allow-same-origin present ⚠)" : "(blocked — no allow-same-origin)"}</div>
|
|
663
|
+
<span id="ls" class="err">testing...</span>
|
|
664
|
+
<script>
|
|
665
|
+
try{localStorage.setItem("t","1");document.getElementById("ls").textContent="${cfg.allowSameOrigin ? "⚠ localStorage accessible (same-origin granted)" : "✓ Blocked — SecurityError"}";
|
|
666
|
+
document.getElementById("ls").className="${cfg.allowSameOrigin ? "err" : "ok"}"}catch(e){document.getElementById("ls").textContent="✓ Blocked: "+e.name;document.getElementById("ls").className="ok"}
|
|
667
|
+
</script>
|
|
668
|
+
<br><br>
|
|
669
|
+
<div class="label">Form submission ${cfg.allowForms ? "(⚠ allow-forms present)" : "(blocked)"}</div>
|
|
670
|
+
<span class="${cfg.allowForms ? "err" : "ok"}">${cfg.allowForms ? "⚠ Forms can submit" : "✓ Forms blocked"}</span>
|
|
671
|
+
</body></html>'
|
|
672
|
+
style="width:100%;border:1px solid #334155;border-radius:4px;min-height:140px"
|
|
673
|
+
></iframe>
|
|
674
|
+
${
|
|
675
|
+
cfg.allowSameOrigin
|
|
676
|
+
? `<div class="box" style="margin-top:0.75rem">
|
|
677
|
+
<div class="label" style="color:#f87171">⚠ Danger: allow-scripts + allow-same-origin</div>
|
|
678
|
+
<span style="font-size:0.75rem;color:#f87171">
|
|
679
|
+
With both tokens, the embedded JS can <strong>remove the sandbox attribute itself</strong>
|
|
680
|
+
by navigating to the same origin without sandbox. Avoid this combination!
|
|
681
|
+
</span>
|
|
682
|
+
</div>`
|
|
683
|
+
: ""
|
|
684
|
+
}
|
|
685
|
+
</body>
|
|
686
|
+
</html>`;
|
|
687
|
+
},
|
|
688
|
+
vulnerableDoc: (_cfg) => `<!doctype html>
|
|
689
|
+
<html>
|
|
690
|
+
<head>
|
|
691
|
+
<style>body{font-family:system-ui;background:#0f172a;color:#e2e8f0;padding:1rem;margin:0}
|
|
692
|
+
.box{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:1rem;margin-bottom:0.75rem}
|
|
693
|
+
.err{color:#f87171;font-weight:600}.label{font-size:0.7rem;color:#64748b;text-transform:uppercase}</style>
|
|
694
|
+
</head>
|
|
695
|
+
<body>
|
|
696
|
+
<div class="box">
|
|
697
|
+
<div class="label">No sandbox — full trust iframe (same origin)</div>
|
|
698
|
+
<span class="err">Scripts run AND localStorage is accessible AND forms submit</span>
|
|
699
|
+
</div>
|
|
700
|
+
<iframe
|
|
701
|
+
srcdoc='<script>document.write("All capabilities granted — no isolation")</script>'
|
|
702
|
+
style="width:100%;border:1px solid #f87171;border-radius:4px;min-height:40px"
|
|
703
|
+
></iframe>
|
|
704
|
+
</body>
|
|
705
|
+
</html>`,
|
|
706
|
+
serverCode: (cfg) => {
|
|
707
|
+
const tokens = [
|
|
708
|
+
"allow-scripts",
|
|
709
|
+
...(cfg.allowSameOrigin ? ["allow-same-origin"] : []),
|
|
710
|
+
...(cfg.allowForms ? ["allow-forms"] : []),
|
|
711
|
+
].join(" ");
|
|
712
|
+
return `<!-- Granting only what the embedded content needs: -->
|
|
713
|
+
<iframe
|
|
714
|
+
sandbox="${tokens}"
|
|
715
|
+
src="/embed/user-widget"
|
|
716
|
+
></iframe>
|
|
717
|
+
|
|
718
|
+
<!--
|
|
719
|
+
Token reference:
|
|
720
|
+
allow-scripts → JS can run (but treat it as a unique origin)
|
|
721
|
+
allow-same-origin → iframe acts as same origin (can access storage)
|
|
722
|
+
allow-forms → <form> elements can submit
|
|
723
|
+
allow-popups → window.open() works
|
|
724
|
+
allow-top-navigation → can navigate the parent window (⚠ phishing risk)
|
|
725
|
+
allow-downloads → can trigger file downloads
|
|
726
|
+
|
|
727
|
+
⚠ allow-scripts + allow-same-origin together is dangerous:
|
|
728
|
+
the embedded script can navigate to the same URL without sandbox,
|
|
729
|
+
effectively removing the restriction.
|
|
730
|
+
-->`;
|
|
731
|
+
},
|
|
732
|
+
explanation: `The \`sandbox\` attribute starts with **no capabilities** and you grant back only what you need. \`allow-scripts\` lets the iframe run JavaScript but treats the content as a **unique, opaque origin** — so it cannot access \`localStorage\`, \`document.cookie\`, or make credentialed requests to your origin.
|
|
733
|
+
|
|
734
|
+
**Critical combination to avoid:** \`allow-scripts\` + \`allow-same-origin\` together. The embedded script can navigate to the same-origin document without the sandbox attribute, effectively escaping the sandbox.`,
|
|
735
|
+
howItHelps: [
|
|
736
|
+
'"Unique origin" treatment means no access to parent\'s cookies or storage',
|
|
737
|
+
"Forms cannot submit — prevents phishing widgets embedded in your page",
|
|
738
|
+
"Top-level navigation is blocked — embedded content can't hijack the browser bar",
|
|
739
|
+
"Permissions Policy further restricts powerful APIs within the sandbox",
|
|
740
|
+
"Works identically for both same-origin and cross-origin iframes",
|
|
741
|
+
],
|
|
742
|
+
interviewPoints: [
|
|
743
|
+
"allow-scripts without allow-same-origin makes the frame a unique opaque origin",
|
|
744
|
+
"The dangerous combo: allow-scripts + allow-same-origin (sandbox escape possible)",
|
|
745
|
+
"CSP frame-src restricts WHICH URLs can be loaded in frames",
|
|
746
|
+
"Micro-frontend teams use sandbox to ensure CSS/JS don't leak between MFEs",
|
|
747
|
+
"Permissions Policy (formerly Feature Policy) is layered on top of sandbox",
|
|
748
|
+
],
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
id: "sandbox-same-origin",
|
|
752
|
+
title: "Clickjacking — frame-ancestors CSP",
|
|
753
|
+
subtitle: "Prevent your page being embedded as a deceptive overlay",
|
|
754
|
+
config: [
|
|
755
|
+
{
|
|
756
|
+
key: "policy",
|
|
757
|
+
label: "frame-ancestors policy",
|
|
758
|
+
type: "select",
|
|
759
|
+
options: ["none", "self", "self + trusted"],
|
|
760
|
+
default: "none",
|
|
761
|
+
},
|
|
762
|
+
],
|
|
763
|
+
headers: (cfg) => {
|
|
764
|
+
const fa =
|
|
765
|
+
cfg.policy === "none"
|
|
766
|
+
? "'none'"
|
|
767
|
+
: cfg.policy === "self"
|
|
768
|
+
? "'self'"
|
|
769
|
+
: "'self' https://trusted-partner.example.com";
|
|
770
|
+
return {
|
|
771
|
+
"Content-Security-Policy": `frame-ancestors ${fa}`,
|
|
772
|
+
"X-Frame-Options":
|
|
773
|
+
cfg.policy === "none"
|
|
774
|
+
? "DENY"
|
|
775
|
+
: cfg.policy === "self"
|
|
776
|
+
? "SAMEORIGIN"
|
|
777
|
+
: "SAMEORIGIN",
|
|
778
|
+
};
|
|
779
|
+
},
|
|
780
|
+
secureDoc: (cfg) => {
|
|
781
|
+
const fa =
|
|
782
|
+
cfg.policy === "none"
|
|
783
|
+
? "'none'"
|
|
784
|
+
: cfg.policy === "self"
|
|
785
|
+
? "'self'"
|
|
786
|
+
: "'self' https://trusted-partner.example.com";
|
|
787
|
+
return `<!doctype html>
|
|
788
|
+
<html>
|
|
789
|
+
<head>
|
|
790
|
+
<meta http-equiv="Content-Security-Policy" content="frame-ancestors ${fa}">
|
|
791
|
+
<style>
|
|
792
|
+
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 1rem; margin: 0; }
|
|
793
|
+
.box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
|
794
|
+
.ok { color: #4ade80; font-weight: 600; }
|
|
795
|
+
.err { color: #f87171; font-weight: 600; }
|
|
796
|
+
.label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
|
797
|
+
</style>
|
|
798
|
+
</head>
|
|
799
|
+
<body>
|
|
800
|
+
<div class="box">
|
|
801
|
+
<div class="label">Active policy</div>
|
|
802
|
+
<code style="color:#94a3b8;font-size:0.75rem">frame-ancestors ${fa}</code>
|
|
803
|
+
</div>
|
|
804
|
+
<div class="box">
|
|
805
|
+
<div class="label">What this means</div>
|
|
806
|
+
<span class="ok">
|
|
807
|
+
${cfg.policy === "none" ? "✓ This page CANNOT be embedded in any iframe anywhere" : cfg.policy === "self" ? "✓ Only same-origin iframes may embed this page" : "✓ Same-origin + trusted-partner.example.com may embed this page"}
|
|
808
|
+
</span>
|
|
809
|
+
</div>
|
|
810
|
+
<div class="box">
|
|
811
|
+
<div class="label">Clickjacking attack scenario (BLOCKED)</div>
|
|
812
|
+
<span style="font-size:0.75rem;color:#94a3b8">
|
|
813
|
+
An attacker builds attacker.com with:<br>
|
|
814
|
+
<iframe src="bank.com/transfer" style="opacity:0;position:absolute" /><br>
|
|
815
|
+
<button style="position:absolute">Win a prize!</button><br><br>
|
|
816
|
+
The user clicks "Win a prize!" but actually clicks the invisible transfer button.<br>
|
|
817
|
+
frame-ancestors blocks the iframe from loading in the first place.
|
|
818
|
+
</span>
|
|
819
|
+
</div>
|
|
820
|
+
</body>
|
|
821
|
+
</html>`;
|
|
822
|
+
},
|
|
823
|
+
vulnerableDoc: (_cfg) => `<!doctype html>
|
|
824
|
+
<html>
|
|
825
|
+
<head>
|
|
826
|
+
<style>
|
|
827
|
+
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 1rem; margin: 0; }
|
|
828
|
+
.box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
|
829
|
+
.err { color: #f87171; font-weight: 600; }
|
|
830
|
+
.label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
|
831
|
+
</style>
|
|
832
|
+
</head>
|
|
833
|
+
<body>
|
|
834
|
+
<div class="box">
|
|
835
|
+
<div class="label">No frame-ancestors — this page can be iframed by ANYONE</div>
|
|
836
|
+
<span class="err">⚠ attacker.com can overlay this page with an invisible iframe</span>
|
|
837
|
+
</div>
|
|
838
|
+
<div class="box">
|
|
839
|
+
<div class="label">Clickjacking attack possible</div>
|
|
840
|
+
<button style="padding:0.5rem 1rem;background:#3b82f6;color:white;border:none;border-radius:4px">
|
|
841
|
+
Transfer $5,000 (normally hidden under "Win a prize" button)
|
|
842
|
+
</button>
|
|
843
|
+
</div>
|
|
844
|
+
</body>
|
|
845
|
+
</html>`,
|
|
846
|
+
serverCode: (cfg) => {
|
|
847
|
+
const fa =
|
|
848
|
+
cfg.policy === "none"
|
|
849
|
+
? "'none'"
|
|
850
|
+
: cfg.policy === "self"
|
|
851
|
+
? "'self'"
|
|
852
|
+
: "'self' https://trusted-partner.example.com";
|
|
853
|
+
return `// Express — prevent clickjacking with frame-ancestors CSP
|
|
854
|
+
// (frame-ancestors replaces the older X-Frame-Options header)
|
|
855
|
+
|
|
856
|
+
app.use((_req, res, next) => {
|
|
857
|
+
res.setHeader(
|
|
858
|
+
"Content-Security-Policy",
|
|
859
|
+
"frame-ancestors ${fa}",
|
|
860
|
+
);
|
|
861
|
+
// Legacy browsers that don't support CSP frame-ancestors:
|
|
862
|
+
res.setHeader(
|
|
863
|
+
"X-Frame-Options",
|
|
864
|
+
"${
|
|
865
|
+
cfg.policy === "none"
|
|
866
|
+
? "DENY"
|
|
867
|
+
: cfg.policy === "self"
|
|
868
|
+
? "SAMEORIGIN"
|
|
869
|
+
: "SAMEORIGIN // (X-Frame-Options has no allow-list support)"
|
|
870
|
+
}",
|
|
871
|
+
);
|
|
872
|
+
next();
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
// Helmet.js does this in one line:
|
|
876
|
+
// app.use(helmet.frameguard({ action: "${cfg.policy === "none" ? "deny" : "sameorigin"}" }));
|
|
877
|
+
// But Helmet doesn't support the "allow-from" use case — use raw CSP for that.`;
|
|
878
|
+
},
|
|
879
|
+
explanation: `**Clickjacking** [UI redress attack] places your page inside an invisible iframe over a deceptive button. The user thinks they're clicking the attacker's button but actually triggers an action on your page (like a bank transfer or account deletion).
|
|
880
|
+
|
|
881
|
+
\`frame-ancestors\` in CSP prevents the browser from embedding your page at all unless the embedding origin is in your allowlist.
|
|
882
|
+
|
|
883
|
+
**frame-ancestors vs X-Frame-Options:** CSP is the modern standard. \`X-Frame-Options\` doesn't support fine-grained origin allowlists — add both for maximum compatibility.`,
|
|
884
|
+
howItHelps: [
|
|
885
|
+
"Browser refuses to embed your page in iframes on unauthorized domains",
|
|
886
|
+
"Works regardless of the attacker's CSS tricks (opacity, z-index)",
|
|
887
|
+
"'none' gives zero framing risk — use for pages like login, account settings, payments",
|
|
888
|
+
"'self' allows legitimate same-origin embedding (e.g. admin dashboards)",
|
|
889
|
+
"Allow-list with specific partner domains supports widget embedding safely",
|
|
890
|
+
],
|
|
891
|
+
interviewPoints: [
|
|
892
|
+
"frame-ancestors is a CSP directive — NOT set via <meta> tag, must be HTTP header",
|
|
893
|
+
"X-Frame-Options DENY = frame-ancestors 'none'; SAMEORIGIN = 'self'",
|
|
894
|
+
"X-Frame-Options ALLOW-FROM is deprecated — use frame-ancestors allow-list instead",
|
|
895
|
+
"Payment APIs (e.g. Stripe Elements) use controlled framing extensively — frame-ancestors enables this",
|
|
896
|
+
"CSRF [Cross-Site Request Forgery] protection is related — SameSite cookies address same threat",
|
|
897
|
+
],
|
|
898
|
+
},
|
|
899
|
+
],
|
|
900
|
+
},
|
|
901
|
+
// ─── XSS ────────────────────────────────────────────────
|
|
902
|
+
{
|
|
903
|
+
id: "xss",
|
|
904
|
+
label: "XSS Prevention",
|
|
905
|
+
color: "text-red-300",
|
|
906
|
+
borderColor: "border-red-500/30",
|
|
907
|
+
bgColor: "bg-red-500/10",
|
|
908
|
+
scenarios: [
|
|
909
|
+
{
|
|
910
|
+
id: "xss-reflect",
|
|
911
|
+
title: "Reflected XSS — innerHTML vs textContent",
|
|
912
|
+
subtitle: "Never put user input into innerHTML without sanitization",
|
|
913
|
+
config: [
|
|
914
|
+
{
|
|
915
|
+
key: "sanitize",
|
|
916
|
+
label: "Sanitize input (use textContent)",
|
|
917
|
+
type: "toggle",
|
|
918
|
+
default: true,
|
|
919
|
+
},
|
|
920
|
+
],
|
|
921
|
+
headers: (cfg) => ({
|
|
922
|
+
"Content-Security-Policy": cfg.sanitize
|
|
923
|
+
? "default-src 'self'; script-src 'self'"
|
|
924
|
+
: "",
|
|
925
|
+
"X-Content-Type-Options": "nosniff",
|
|
926
|
+
}),
|
|
927
|
+
secureDoc: (cfg) => `<!doctype html>
|
|
928
|
+
<html>
|
|
929
|
+
<head>
|
|
930
|
+
${cfg.sanitize ? `<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">` : ""}
|
|
931
|
+
<style>
|
|
932
|
+
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 1rem; margin: 0; }
|
|
933
|
+
.box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
|
934
|
+
.ok { color: #4ade80; font-weight: 600; }
|
|
935
|
+
.err { color: #f87171; font-weight: 600; }
|
|
936
|
+
.label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
|
937
|
+
input { background:#0f172a;border:1px solid #475569;border-radius:4px;color:#e2e8f0;padding:0.25rem 0.5rem;font-size:0.8rem;width:100%;box-sizing:border-box;margin-top:0.25rem }
|
|
938
|
+
button { margin-top:0.5rem;background:#3b82f6;color:white;border:none;border-radius:4px;padding:0.35rem 0.75rem;cursor:pointer;font-size:0.8rem }
|
|
939
|
+
</style>
|
|
940
|
+
</head>
|
|
941
|
+
<body>
|
|
942
|
+
<div class="box">
|
|
943
|
+
<div class="label">Mode: ${cfg.sanitize ? "Safe (textContent)" : "⚠ Vulnerable (innerHTML)"}</div>
|
|
944
|
+
<input id="userInput" value='Hello <img src=x onerror="alert(1)"> <script>alert(2)</s' + 'cript>' placeholder="Try: <img src=x onerror=alert(1)>">
|
|
945
|
+
<button onclick="renderOutput()">Render</button>
|
|
946
|
+
</div>
|
|
947
|
+
<div class="box">
|
|
948
|
+
<div class="label">Output</div>
|
|
949
|
+
<div id="output" style="min-height:2rem"></div>
|
|
950
|
+
</div>
|
|
951
|
+
<div class="box">
|
|
952
|
+
<div class="label">Security note</div>
|
|
953
|
+
<span class="${cfg.sanitize ? "ok" : "err"}" style="font-size:0.75rem">
|
|
954
|
+
${
|
|
955
|
+
cfg.sanitize
|
|
956
|
+
? "✓ textContent treats input as plain text — HTML tags are escaped automatically"
|
|
957
|
+
: "⚠ innerHTML interprets HTML — attacker can inject onerror= and script tags"
|
|
958
|
+
}
|
|
959
|
+
</span>
|
|
960
|
+
</div>
|
|
961
|
+
<script>
|
|
962
|
+
function renderOutput() {
|
|
963
|
+
const input = document.getElementById('userInput').value;
|
|
964
|
+
const output = document.getElementById('output');
|
|
965
|
+
${
|
|
966
|
+
cfg.sanitize
|
|
967
|
+
? `// Safe: textContent never interprets HTML
|
|
968
|
+
output.textContent = input;`
|
|
969
|
+
: `// ⚠ Vulnerable: innerHTML executes injected HTML/JS
|
|
970
|
+
output.innerHTML = input;`
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
</script>
|
|
974
|
+
</body>
|
|
975
|
+
</html>`,
|
|
976
|
+
vulnerableDoc: (_cfg) => `<!doctype html>
|
|
977
|
+
<html>
|
|
978
|
+
<head>
|
|
979
|
+
<style>
|
|
980
|
+
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 1rem; margin: 0; }
|
|
981
|
+
.box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
|
|
982
|
+
.err { color: #f87171; font-weight: 600; }
|
|
983
|
+
.label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
|
|
984
|
+
input { background:#0f172a;border:1px solid #475569;border-radius:4px;color:#e2e8f0;padding:0.25rem 0.5rem;font-size:0.8rem;width:100%;box-sizing:border-box;margin-top:0.25rem }
|
|
985
|
+
button { margin-top:0.5rem;background:#ef4444;color:white;border:none;border-radius:4px;padding:0.35rem 0.75rem;cursor:pointer;font-size:0.8rem }
|
|
986
|
+
</style>
|
|
987
|
+
</head>
|
|
988
|
+
<body>
|
|
989
|
+
<div class="box">
|
|
990
|
+
<div class="label">⚠ innerHTML — try: <img src=x onerror=alert(1)></div>
|
|
991
|
+
<input id="i" value='<img src=x onerror="alert(1)">'>
|
|
992
|
+
<button onclick="document.getElementById('o').innerHTML = document.getElementById('i').value">Render</button>
|
|
993
|
+
</div>
|
|
994
|
+
<div class="box" id="o"></div>
|
|
995
|
+
<div class="box">
|
|
996
|
+
<span class="err">⚠ No CSP, no sanitization — any HTML including event handlers will execute</span>
|
|
997
|
+
</div>
|
|
998
|
+
</body>
|
|
999
|
+
</html>`,
|
|
1000
|
+
serverCode: (
|
|
1001
|
+
cfg,
|
|
1002
|
+
) => `// Server-side: encode output in templates (last line of defense)
|
|
1003
|
+
// Using a template engine with auto-escaping (Handlebars, EJS with <%=):
|
|
1004
|
+
// ✅ {{userComment}} — auto-escaped by Handlebars
|
|
1005
|
+
// ✅ <%= userComment %> — auto-escaped by EJS
|
|
1006
|
+
// ❌ {{{userComment}}} — triple-mustache = raw HTML output
|
|
1007
|
+
|
|
1008
|
+
// Client-side: ALWAYS prefer textContent / setAttribute over innerHTML
|
|
1009
|
+
const el = document.getElementById("output");
|
|
1010
|
+
|
|
1011
|
+
// ✅ Safe
|
|
1012
|
+
el.textContent = userSuppliedInput;
|
|
1013
|
+
|
|
1014
|
+
// ✅ Safe for attributes
|
|
1015
|
+
el.setAttribute("href", sanitizeUrl(userUrl));
|
|
1016
|
+
|
|
1017
|
+
// ❌ Dangerous — executes injected HTML
|
|
1018
|
+
el.innerHTML = userSuppliedInput;
|
|
1019
|
+
|
|
1020
|
+
// ❌ Dangerous — executes injected JS
|
|
1021
|
+
el.innerHTML = \`<a href="\${userUrl}">click</a>\`;
|
|
1022
|
+
|
|
1023
|
+
${
|
|
1024
|
+
cfg.sanitize
|
|
1025
|
+
? `// If you genuinely need to render HTML (e.g. rich text editor):
|
|
1026
|
+
// Use DOMPurify — the gold-standard HTML sanitizer
|
|
1027
|
+
import DOMPurify from "dompurify";
|
|
1028
|
+
el.innerHTML = DOMPurify.sanitize(richHtmlContent);`
|
|
1029
|
+
: "// ⚠ No sanitization active — innerHTML with user input is an XSS sink"
|
|
1030
|
+
}`,
|
|
1031
|
+
explanation: `**Reflected XSS** [Cross-Site Scripting] happens when user-supplied input is inserted into a page without escaping. The classic sink is \`innerHTML\`.
|
|
1032
|
+
|
|
1033
|
+
\`textContent\` is immune: it treats any input as plain text and escapes \`<\`, \`>\`, \`&\` automatically — HTML tags become visible text, not executable elements.
|
|
1034
|
+
|
|
1035
|
+
**Defense layers:**
|
|
1036
|
+
1. **Output encoding** — escape HTML entities server-side in templates
|
|
1037
|
+
2. **textContent** over \`innerHTML\` in client code
|
|
1038
|
+
3. **DOMPurify** if you must render HTML (rich text editors)
|
|
1039
|
+
4. **CSP** as a final backstop even if encoding fails`,
|
|
1040
|
+
howItHelps: [
|
|
1041
|
+
"textContent is always safe — there is no XSS vector through it",
|
|
1042
|
+
"Template engines with auto-escaping handle server-rendered output safely",
|
|
1043
|
+
"DOMPurify strips dangerous attributes (onerror, onload) from HTML",
|
|
1044
|
+
"CSP blocks inline script execution even if injection succeeds",
|
|
1045
|
+
"Layered defense: one control failing doesn't compromise the whole app",
|
|
1046
|
+
],
|
|
1047
|
+
interviewPoints: [
|
|
1048
|
+
"innerHTML, outerHTML, document.write(), and eval() are the main XSS sinks",
|
|
1049
|
+
"React's JSX auto-escapes — dangerouslySetInnerHTML is the only opt-out",
|
|
1050
|
+
"Angular's DomSanitizer and Vue's v-html require explicit trust marking",
|
|
1051
|
+
"Stored XSS (in database) is more dangerous than reflected — CSP helps both",
|
|
1052
|
+
"DOM-based XSS occurs entirely client-side — server-side escaping doesn't prevent it",
|
|
1053
|
+
],
|
|
1054
|
+
},
|
|
1055
|
+
],
|
|
1056
|
+
},
|
|
1057
|
+
];
|
|
1058
|
+
|
|
1059
|
+
// ═══════════════════════════════════════════════════════ Helpers
|
|
1060
|
+
|
|
1061
|
+
function CodeBlock({
|
|
1062
|
+
code,
|
|
1063
|
+
language = "code",
|
|
1064
|
+
}: {
|
|
1065
|
+
code: string;
|
|
1066
|
+
language?: string;
|
|
1067
|
+
}) {
|
|
1068
|
+
const [copied, setCopied] = useState(false);
|
|
1069
|
+
const copy = () => {
|
|
1070
|
+
navigator.clipboard.writeText(code).then(() => {
|
|
1071
|
+
setCopied(true);
|
|
1072
|
+
setTimeout(() => setCopied(false), 1800);
|
|
1073
|
+
});
|
|
1074
|
+
};
|
|
1075
|
+
return (
|
|
1076
|
+
<div className="relative rounded-lg overflow-hidden border border-slate-700/60">
|
|
1077
|
+
<div className="flex items-center justify-between px-3 py-1.5 bg-slate-800/80 border-b border-slate-700/60">
|
|
1078
|
+
<span className="text-[10px] text-slate-500 uppercase tracking-wider">
|
|
1079
|
+
{language}
|
|
1080
|
+
</span>
|
|
1081
|
+
<button
|
|
1082
|
+
onClick={copy}
|
|
1083
|
+
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-slate-300 transition-colors"
|
|
1084
|
+
>
|
|
1085
|
+
{copied ? (
|
|
1086
|
+
<Check className="w-3 h-3 text-green-400" />
|
|
1087
|
+
) : (
|
|
1088
|
+
<Copy className="w-3 h-3" />
|
|
1089
|
+
)}
|
|
1090
|
+
{copied ? "Copied" : "Copy"}
|
|
1091
|
+
</button>
|
|
1092
|
+
</div>
|
|
1093
|
+
<pre className="p-3 text-[11px] leading-relaxed text-slate-300 overflow-x-auto bg-slate-900/60 whitespace-pre-wrap">
|
|
1094
|
+
{code}
|
|
1095
|
+
</pre>
|
|
1096
|
+
</div>
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// ═══════════════════════════════════════════════════════ Main Modal
|
|
1101
|
+
|
|
1102
|
+
export default function BrowserSecurityLabModal() {
|
|
1103
|
+
const { closeBrowserSecurityLab } = useStore();
|
|
1104
|
+
|
|
1105
|
+
const allScenarios = SCENARIOS.flatMap((g) => g.scenarios);
|
|
1106
|
+
const [selectedId, setSelectedId] = useState<ScenarioId>("csp-basics");
|
|
1107
|
+
const [tab, setTab] = useState<TabId>("preview");
|
|
1108
|
+
const [cfg, setCfg] = useState<Cfg>({});
|
|
1109
|
+
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
|
|
1110
|
+
new Set(),
|
|
1111
|
+
);
|
|
1112
|
+
const secureRef = useRef<HTMLIFrameElement>(null);
|
|
1113
|
+
const vulnerableRef = useRef<HTMLIFrameElement>(null);
|
|
1114
|
+
const [showVulnerable, setShowVulnerable] = useState(true);
|
|
1115
|
+
|
|
1116
|
+
const scenario = allScenarios.find((s) => s.id === selectedId)!;
|
|
1117
|
+
|
|
1118
|
+
// Initialise cfg when scenario changes
|
|
1119
|
+
useEffect(() => {
|
|
1120
|
+
const defaults: Cfg = {};
|
|
1121
|
+
scenario.config?.forEach((f) => {
|
|
1122
|
+
defaults[f.key] = f.default;
|
|
1123
|
+
});
|
|
1124
|
+
setCfg(defaults);
|
|
1125
|
+
setTab("preview");
|
|
1126
|
+
}, [selectedId]);
|
|
1127
|
+
|
|
1128
|
+
// Update iframes when cfg changes
|
|
1129
|
+
const refreshFrames = useCallback(() => {
|
|
1130
|
+
if (secureRef.current) {
|
|
1131
|
+
secureRef.current.srcdoc = scenario.secureDoc(cfg);
|
|
1132
|
+
}
|
|
1133
|
+
if (vulnerableRef.current) {
|
|
1134
|
+
vulnerableRef.current.srcdoc = scenario.vulnerableDoc(cfg);
|
|
1135
|
+
}
|
|
1136
|
+
}, [scenario, cfg]);
|
|
1137
|
+
|
|
1138
|
+
useEffect(() => {
|
|
1139
|
+
refreshFrames();
|
|
1140
|
+
}, [refreshFrames]);
|
|
1141
|
+
|
|
1142
|
+
const group = SCENARIOS.find((g) =>
|
|
1143
|
+
g.scenarios.some((s) => s.id === selectedId),
|
|
1144
|
+
)!;
|
|
1145
|
+
|
|
1146
|
+
const TABS: { id: TabId; label: string }[] = [
|
|
1147
|
+
{ id: "preview", label: "Live Preview" },
|
|
1148
|
+
{ id: "code", label: "Server Code" },
|
|
1149
|
+
{ id: "explanation", label: "Explanation" },
|
|
1150
|
+
{ id: "checklist", label: "Interview Points" },
|
|
1151
|
+
];
|
|
1152
|
+
|
|
1153
|
+
return (
|
|
1154
|
+
<div className="fixed inset-0 z-50 flex items-stretch bg-slate-950/90 backdrop-blur-sm">
|
|
1155
|
+
<div className="flex w-full h-full overflow-hidden">
|
|
1156
|
+
{/* ── Left panel — scenario picker ─────────────────────── */}
|
|
1157
|
+
<div className="w-56 shrink-0 bg-slate-900 border-r border-slate-800 flex flex-col overflow-hidden">
|
|
1158
|
+
{/* Header */}
|
|
1159
|
+
<div className="px-3 py-3 border-b border-slate-800 flex items-center gap-2 shrink-0">
|
|
1160
|
+
<Shield className="w-4 h-4 text-cyan-400" />
|
|
1161
|
+
<span className="text-sm font-semibold text-slate-200">
|
|
1162
|
+
Browser Security
|
|
1163
|
+
</span>
|
|
1164
|
+
<button
|
|
1165
|
+
onClick={closeBrowserSecurityLab}
|
|
1166
|
+
className="ml-auto p-0.5 rounded text-slate-600 hover:text-slate-300 transition-colors"
|
|
1167
|
+
>
|
|
1168
|
+
<X className="w-4 h-4" />
|
|
1169
|
+
</button>
|
|
1170
|
+
</div>
|
|
1171
|
+
|
|
1172
|
+
{/* Scenario groups */}
|
|
1173
|
+
<div className="flex-1 overflow-y-auto py-2">
|
|
1174
|
+
{SCENARIOS.map((grp) => {
|
|
1175
|
+
const isCollapsed = collapsedGroups.has(grp.id);
|
|
1176
|
+
return (
|
|
1177
|
+
<div key={grp.id} className="mb-1">
|
|
1178
|
+
<button
|
|
1179
|
+
onClick={() =>
|
|
1180
|
+
setCollapsedGroups((prev) => {
|
|
1181
|
+
const next = new Set(prev);
|
|
1182
|
+
if (next.has(grp.id)) next.delete(grp.id);
|
|
1183
|
+
else next.add(grp.id);
|
|
1184
|
+
return next;
|
|
1185
|
+
})
|
|
1186
|
+
}
|
|
1187
|
+
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left hover:bg-slate-800/50 transition-colors"
|
|
1188
|
+
>
|
|
1189
|
+
{isCollapsed ? (
|
|
1190
|
+
<ChevronRight className="w-3 h-3 text-slate-500 shrink-0" />
|
|
1191
|
+
) : (
|
|
1192
|
+
<ChevronDown className="w-3 h-3 text-slate-500 shrink-0" />
|
|
1193
|
+
)}
|
|
1194
|
+
<span
|
|
1195
|
+
className={`text-[10px] font-semibold uppercase tracking-wider ${grp.color}`}
|
|
1196
|
+
>
|
|
1197
|
+
{grp.label}
|
|
1198
|
+
</span>
|
|
1199
|
+
</button>
|
|
1200
|
+
|
|
1201
|
+
{!isCollapsed &&
|
|
1202
|
+
grp.scenarios.map((s) => {
|
|
1203
|
+
const isActive = s.id === selectedId;
|
|
1204
|
+
return (
|
|
1205
|
+
<button
|
|
1206
|
+
key={s.id}
|
|
1207
|
+
onClick={() => setSelectedId(s.id)}
|
|
1208
|
+
className={`w-full text-left px-4 py-2 transition-colors border-l-2 ${
|
|
1209
|
+
isActive
|
|
1210
|
+
? `${grp.bgColor} ${grp.borderColor.replace("border-", "border-l-")} border-opacity-100`
|
|
1211
|
+
: "border-l-transparent hover:bg-slate-800/30"
|
|
1212
|
+
}`}
|
|
1213
|
+
>
|
|
1214
|
+
<div
|
|
1215
|
+
className={`text-[11px] font-medium leading-snug ${isActive ? grp.color : "text-slate-400"}`}
|
|
1216
|
+
>
|
|
1217
|
+
{s.title}
|
|
1218
|
+
</div>
|
|
1219
|
+
<div className="text-[10px] text-slate-600 mt-0.5 leading-snug">
|
|
1220
|
+
{s.subtitle}
|
|
1221
|
+
</div>
|
|
1222
|
+
</button>
|
|
1223
|
+
);
|
|
1224
|
+
})}
|
|
1225
|
+
</div>
|
|
1226
|
+
);
|
|
1227
|
+
})}
|
|
1228
|
+
</div>
|
|
1229
|
+
</div>
|
|
1230
|
+
|
|
1231
|
+
{/* ── Centre — config + tabs ───────────────────────────── */}
|
|
1232
|
+
<div className="flex flex-col flex-1 overflow-hidden">
|
|
1233
|
+
{/* Scenario title bar */}
|
|
1234
|
+
<div className="shrink-0 px-4 py-3 border-b border-slate-800 flex items-center gap-3 bg-slate-900/60">
|
|
1235
|
+
<div>
|
|
1236
|
+
<h2 className={`text-sm font-semibold ${group.color}`}>
|
|
1237
|
+
{scenario.title}
|
|
1238
|
+
</h2>
|
|
1239
|
+
<p className="text-[11px] text-slate-500 mt-0.5">
|
|
1240
|
+
{scenario.subtitle}
|
|
1241
|
+
</p>
|
|
1242
|
+
</div>
|
|
1243
|
+
|
|
1244
|
+
{/* Config controls */}
|
|
1245
|
+
{scenario.config && scenario.config.length > 0 && (
|
|
1246
|
+
<div className="ml-auto flex items-center gap-3 flex-wrap justify-end">
|
|
1247
|
+
{scenario.config.map((field) => (
|
|
1248
|
+
<div key={field.key} className="flex items-center gap-1.5">
|
|
1249
|
+
{field.type === "toggle" && (
|
|
1250
|
+
<>
|
|
1251
|
+
<span className="text-[10px] text-slate-500">
|
|
1252
|
+
{field.label}
|
|
1253
|
+
</span>
|
|
1254
|
+
<button
|
|
1255
|
+
onClick={() =>
|
|
1256
|
+
setCfg((prev) => ({
|
|
1257
|
+
...prev,
|
|
1258
|
+
[field.key]: !prev[field.key],
|
|
1259
|
+
}))
|
|
1260
|
+
}
|
|
1261
|
+
className={`relative w-8 h-4 rounded-full transition-colors ${
|
|
1262
|
+
cfg[field.key] ? "bg-cyan-500" : "bg-slate-700"
|
|
1263
|
+
}`}
|
|
1264
|
+
>
|
|
1265
|
+
<span
|
|
1266
|
+
className={`absolute top-0.5 w-3 h-3 rounded-full bg-white transition-transform ${
|
|
1267
|
+
cfg[field.key]
|
|
1268
|
+
? "translate-x-4"
|
|
1269
|
+
: "translate-x-0.5"
|
|
1270
|
+
}`}
|
|
1271
|
+
/>
|
|
1272
|
+
</button>
|
|
1273
|
+
</>
|
|
1274
|
+
)}
|
|
1275
|
+
{field.type === "select" && (
|
|
1276
|
+
<>
|
|
1277
|
+
<span className="text-[10px] text-slate-500">
|
|
1278
|
+
{field.label}:
|
|
1279
|
+
</span>
|
|
1280
|
+
<select
|
|
1281
|
+
value={String(cfg[field.key] ?? field.default)}
|
|
1282
|
+
onChange={(e) =>
|
|
1283
|
+
setCfg((prev) => ({
|
|
1284
|
+
...prev,
|
|
1285
|
+
[field.key]: e.target.value,
|
|
1286
|
+
}))
|
|
1287
|
+
}
|
|
1288
|
+
className="text-[11px] bg-slate-800 border border-slate-700 text-slate-300 rounded px-1.5 py-0.5 focus:outline-none focus:border-cyan-500"
|
|
1289
|
+
>
|
|
1290
|
+
{field.options?.map((o) => (
|
|
1291
|
+
<option key={o} value={o}>
|
|
1292
|
+
{o}
|
|
1293
|
+
</option>
|
|
1294
|
+
))}
|
|
1295
|
+
</select>
|
|
1296
|
+
</>
|
|
1297
|
+
)}
|
|
1298
|
+
</div>
|
|
1299
|
+
))}
|
|
1300
|
+
<button
|
|
1301
|
+
onClick={refreshFrames}
|
|
1302
|
+
className="flex items-center gap-1 text-[11px] px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-300 transition-colors"
|
|
1303
|
+
>
|
|
1304
|
+
<Play className="w-3 h-3" />
|
|
1305
|
+
Apply
|
|
1306
|
+
</button>
|
|
1307
|
+
</div>
|
|
1308
|
+
)}
|
|
1309
|
+
</div>
|
|
1310
|
+
|
|
1311
|
+
{/* Tab bar */}
|
|
1312
|
+
<div className="shrink-0 flex border-b border-slate-800 bg-slate-900/40 px-2">
|
|
1313
|
+
{TABS.map((t) => (
|
|
1314
|
+
<button
|
|
1315
|
+
key={t.id}
|
|
1316
|
+
onClick={() => setTab(t.id)}
|
|
1317
|
+
className={`px-3 py-2 text-[11px] font-medium transition-colors border-b-2 ${
|
|
1318
|
+
tab === t.id
|
|
1319
|
+
? `${group.color} border-current`
|
|
1320
|
+
: "text-slate-500 border-transparent hover:text-slate-300"
|
|
1321
|
+
}`}
|
|
1322
|
+
>
|
|
1323
|
+
{t.label}
|
|
1324
|
+
</button>
|
|
1325
|
+
))}
|
|
1326
|
+
|
|
1327
|
+
{tab === "preview" && (
|
|
1328
|
+
<div className="ml-auto flex items-center gap-2 py-1 pr-1">
|
|
1329
|
+
<span className="text-[10px] text-slate-600">Compare:</span>
|
|
1330
|
+
<button
|
|
1331
|
+
onClick={() => setShowVulnerable((v) => !v)}
|
|
1332
|
+
className={`text-[10px] px-2 py-0.5 rounded transition-colors ${
|
|
1333
|
+
showVulnerable
|
|
1334
|
+
? "bg-red-500/20 text-red-300 border border-red-500/30"
|
|
1335
|
+
: "bg-slate-700 text-slate-400 hover:bg-slate-600"
|
|
1336
|
+
}`}
|
|
1337
|
+
>
|
|
1338
|
+
{showVulnerable ? "Hide vulnerable" : "Show vulnerable"}
|
|
1339
|
+
</button>
|
|
1340
|
+
</div>
|
|
1341
|
+
)}
|
|
1342
|
+
</div>
|
|
1343
|
+
|
|
1344
|
+
{/* Tab content */}
|
|
1345
|
+
<div className="flex-1 overflow-hidden flex">
|
|
1346
|
+
{tab === "preview" && (
|
|
1347
|
+
<div className="flex-1 flex gap-0 overflow-hidden">
|
|
1348
|
+
{/* Secure preview */}
|
|
1349
|
+
<div
|
|
1350
|
+
className={`flex flex-col overflow-hidden ${showVulnerable ? "w-1/2" : "w-full"}`}
|
|
1351
|
+
>
|
|
1352
|
+
<div className="px-3 py-1.5 bg-emerald-900/20 border-b border-emerald-700/30 flex items-center gap-2 shrink-0">
|
|
1353
|
+
<ShieldCheck className="w-3 h-3 text-emerald-400" />
|
|
1354
|
+
<span className="text-[10px] font-semibold text-emerald-400 uppercase tracking-wider">
|
|
1355
|
+
Secure
|
|
1356
|
+
</span>
|
|
1357
|
+
<div className="ml-auto flex gap-1 flex-wrap">
|
|
1358
|
+
{Object.entries(scenario.headers(cfg)).map(
|
|
1359
|
+
([k, v]) =>
|
|
1360
|
+
v && (
|
|
1361
|
+
<span
|
|
1362
|
+
key={k}
|
|
1363
|
+
className="text-[9px] bg-emerald-900/40 border border-emerald-700/30 text-emerald-400/70 rounded px-1 py-0.5 font-mono"
|
|
1364
|
+
>
|
|
1365
|
+
{k}
|
|
1366
|
+
</span>
|
|
1367
|
+
),
|
|
1368
|
+
)}
|
|
1369
|
+
</div>
|
|
1370
|
+
</div>
|
|
1371
|
+
<iframe
|
|
1372
|
+
ref={secureRef}
|
|
1373
|
+
sandbox="allow-scripts allow-same-origin"
|
|
1374
|
+
className="flex-1 border-0 bg-slate-950"
|
|
1375
|
+
title="Secure preview"
|
|
1376
|
+
/>
|
|
1377
|
+
</div>
|
|
1378
|
+
|
|
1379
|
+
{/* Divider */}
|
|
1380
|
+
{showVulnerable && (
|
|
1381
|
+
<div className="w-px bg-slate-800 shrink-0" />
|
|
1382
|
+
)}
|
|
1383
|
+
|
|
1384
|
+
{/* Vulnerable preview */}
|
|
1385
|
+
{showVulnerable && (
|
|
1386
|
+
<div className="w-1/2 flex flex-col overflow-hidden">
|
|
1387
|
+
<div className="px-3 py-1.5 bg-red-900/20 border-b border-red-700/30 flex items-center gap-2 shrink-0">
|
|
1388
|
+
<ShieldOff className="w-3 h-3 text-red-400" />
|
|
1389
|
+
<span className="text-[10px] font-semibold text-red-400 uppercase tracking-wider">
|
|
1390
|
+
Vulnerable (no protection)
|
|
1391
|
+
</span>
|
|
1392
|
+
</div>
|
|
1393
|
+
<iframe
|
|
1394
|
+
ref={vulnerableRef}
|
|
1395
|
+
sandbox="allow-scripts allow-same-origin"
|
|
1396
|
+
className="flex-1 border-0 bg-slate-950"
|
|
1397
|
+
title="Vulnerable preview"
|
|
1398
|
+
/>
|
|
1399
|
+
</div>
|
|
1400
|
+
)}
|
|
1401
|
+
</div>
|
|
1402
|
+
)}
|
|
1403
|
+
|
|
1404
|
+
{tab === "code" && (
|
|
1405
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
1406
|
+
{/* Response headers panel */}
|
|
1407
|
+
<div className="rounded-lg border border-slate-700/60 overflow-hidden">
|
|
1408
|
+
<div className="px-3 py-2 bg-slate-800/60 border-b border-slate-700/60">
|
|
1409
|
+
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">
|
|
1410
|
+
HTTP Response Headers
|
|
1411
|
+
</span>
|
|
1412
|
+
</div>
|
|
1413
|
+
<div className="bg-slate-900/60 p-3 space-y-1">
|
|
1414
|
+
{Object.entries(scenario.headers(cfg))
|
|
1415
|
+
.filter(([, v]) => v)
|
|
1416
|
+
.map(([k, v]) => (
|
|
1417
|
+
<div
|
|
1418
|
+
key={k}
|
|
1419
|
+
className="flex gap-2 font-mono text-[11px]"
|
|
1420
|
+
>
|
|
1421
|
+
<span className="text-cyan-400 shrink-0">{k}:</span>
|
|
1422
|
+
<span className="text-slate-300 break-all">{v}</span>
|
|
1423
|
+
</div>
|
|
1424
|
+
))}
|
|
1425
|
+
{Object.values(scenario.headers(cfg)).every((v) => !v) && (
|
|
1426
|
+
<span className="text-[11px] text-red-400/70 italic">
|
|
1427
|
+
No security headers set
|
|
1428
|
+
</span>
|
|
1429
|
+
)}
|
|
1430
|
+
</div>
|
|
1431
|
+
</div>
|
|
1432
|
+
|
|
1433
|
+
{/* Server implementation code */}
|
|
1434
|
+
<CodeBlock
|
|
1435
|
+
code={scenario.serverCode(cfg)}
|
|
1436
|
+
language="javascript (Node.js / Express)"
|
|
1437
|
+
/>
|
|
1438
|
+
</div>
|
|
1439
|
+
)}
|
|
1440
|
+
|
|
1441
|
+
{tab === "explanation" && (
|
|
1442
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
1443
|
+
{/* Explanation markdown-ish */}
|
|
1444
|
+
<div className="rounded-lg border border-slate-700/60 bg-slate-900/40 p-4">
|
|
1445
|
+
<div className="flex items-center gap-2 mb-3">
|
|
1446
|
+
<Shield className="w-4 h-4 text-cyan-400" />
|
|
1447
|
+
<h3 className="text-sm font-semibold text-slate-200">
|
|
1448
|
+
How it works
|
|
1449
|
+
</h3>
|
|
1450
|
+
</div>
|
|
1451
|
+
<div className="text-[12px] text-slate-300 leading-relaxed whitespace-pre-line">
|
|
1452
|
+
{scenario.explanation}
|
|
1453
|
+
</div>
|
|
1454
|
+
</div>
|
|
1455
|
+
|
|
1456
|
+
{/* How it helps */}
|
|
1457
|
+
<div className="rounded-lg border border-emerald-700/30 bg-emerald-900/10 p-4">
|
|
1458
|
+
<div className="flex items-center gap-2 mb-3">
|
|
1459
|
+
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
|
1460
|
+
<h3 className="text-sm font-semibold text-emerald-300">
|
|
1461
|
+
Why it helps
|
|
1462
|
+
</h3>
|
|
1463
|
+
</div>
|
|
1464
|
+
<ul className="space-y-1.5">
|
|
1465
|
+
{scenario.howItHelps.map((pt, i) => (
|
|
1466
|
+
<li
|
|
1467
|
+
key={i}
|
|
1468
|
+
className="flex items-start gap-2 text-[12px] text-slate-300"
|
|
1469
|
+
>
|
|
1470
|
+
<span className="text-emerald-400 shrink-0 mt-0.5">
|
|
1471
|
+
✓
|
|
1472
|
+
</span>
|
|
1473
|
+
{pt}
|
|
1474
|
+
</li>
|
|
1475
|
+
))}
|
|
1476
|
+
</ul>
|
|
1477
|
+
</div>
|
|
1478
|
+
</div>
|
|
1479
|
+
)}
|
|
1480
|
+
|
|
1481
|
+
{tab === "checklist" && (
|
|
1482
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
1483
|
+
<div className="rounded-lg border border-amber-700/30 bg-amber-900/10 p-4">
|
|
1484
|
+
<div className="flex items-center gap-2 mb-3">
|
|
1485
|
+
<ShieldAlert className="w-4 h-4 text-amber-400" />
|
|
1486
|
+
<h3 className="text-sm font-semibold text-amber-300">
|
|
1487
|
+
Interview talking points
|
|
1488
|
+
</h3>
|
|
1489
|
+
</div>
|
|
1490
|
+
<ul className="space-y-3">
|
|
1491
|
+
{scenario.interviewPoints.map((pt, i) => (
|
|
1492
|
+
<li key={i} className="flex items-start gap-2">
|
|
1493
|
+
<span className="w-5 h-5 rounded-full bg-amber-900/40 border border-amber-700/40 text-amber-400 text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">
|
|
1494
|
+
{i + 1}
|
|
1495
|
+
</span>
|
|
1496
|
+
<span className="text-[12px] text-slate-300 leading-relaxed">
|
|
1497
|
+
{pt}
|
|
1498
|
+
</span>
|
|
1499
|
+
</li>
|
|
1500
|
+
))}
|
|
1501
|
+
</ul>
|
|
1502
|
+
</div>
|
|
1503
|
+
</div>
|
|
1504
|
+
)}
|
|
1505
|
+
</div>
|
|
1506
|
+
</div>
|
|
1507
|
+
</div>
|
|
1508
|
+
</div>
|
|
1509
|
+
);
|
|
1510
|
+
}
|