diff --git a/src/components/mdx/A.astro b/src/components/mdx/A.astro new file mode 100644 index 0000000..2b3dac6 --- /dev/null +++ b/src/components/mdx/A.astro @@ -0,0 +1,81 @@ +--- +interface Props { + label?: string; + quality?: 'strong' | 'partial' | 'weak'; +} + +const { label = 'Candidate', quality } = Astro.props; + +const qualityConfig: Record = { + strong: { icon: '✓', text: 'strong answer', color: 'var(--iv-quality-strong)' }, + partial: { icon: '◐', text: 'partial answer', color: 'var(--iv-quality-partial)' }, + weak: { icon: '✗', text: 'weak answer', color: 'var(--iv-quality-weak)' }, +}; + +const q = quality ? qualityConfig[quality] : null; +--- + +
+
+ {q && ( + + + {q.text} + + )} + + + {label} + +
+
+ +
+
+ + diff --git a/src/components/mdx/AttackSurface.astro b/src/components/mdx/AttackSurface.astro new file mode 100644 index 0000000..f84fcb1 --- /dev/null +++ b/src/components/mdx/AttackSurface.astro @@ -0,0 +1,240 @@ +--- +interface Props { + title?: string; + caption?: string; +} + +const { title, caption } = Astro.props; + +const layers = [ + { + id: 'dom', + label: 'Layer 1 — DOM & Browser', + sublabel: 'Injection, execution context, browser trust model', + posts: ['Post 1 · XSS Anatomy', 'Post 2 · Content Security Policy'], + color: 'red', + icon: '🌐', + }, + { + id: 'network', + label: 'Layer 2 — Network & Origins', + sublabel: 'Same-Origin Policy, cross-origin requests, CORS', + posts: ['Post 3 · CORS Deep Dive'], + color: 'orange', + icon: '🔀', + }, + { + id: 'auth', + label: 'Layer 3 — Auth & Cookies', + sublabel: 'Session cookies, cross-site requests, state mutations', + posts: ['Post 4 · CSRF & SameSite'], + color: 'amber', + icon: '🍪', + }, + { + id: 'supply', + label: 'Layer 4 — Supply Chain & Third-Party Trust', + sublabel: 'npm dependency tree, CDN scripts, subresource integrity', + posts: ['Post 5 · Supply Chain Attacks', 'Post 6 · Subresource Integrity'], + color: 'purple', + icon: '📦', + }, +] as const; + +type LayerColor = 'red' | 'orange' | 'amber' | 'purple'; + +const COLOR_VARS: Record = { + red: { bg: 'rgba(239,68,68,0.07)', border: 'rgba(239,68,68,0.35)', badge: 'rgba(239,68,68,0.15)', badgeText: '#f87171' }, + orange: { bg: 'rgba(249,115,22,0.07)', border: 'rgba(249,115,22,0.35)', badge: 'rgba(249,115,22,0.15)', badgeText: '#fb923c' }, + amber: { bg: 'rgba(245,158,11,0.07)', border: 'rgba(245,158,11,0.35)', badge: 'rgba(245,158,11,0.15)', badgeText: '#fbbf24' }, + purple: { bg: 'rgba(168,85,247,0.07)', border: 'rgba(168,85,247,0.35)', badge: 'rgba(168,85,247,0.15)', badgeText: '#c084fc' }, +}; +--- + +
+ {title &&
{title}
} +
+
DevForum
+
+ {layers.map((layer) => { + const c = COLOR_VARS[layer.color]; + return ( +
+
+ +
+
{layer.label}
+
{layer.sublabel}
+
+
+
+ {layer.posts.map(post => ( + + {post} + + ))} +
+
+ ); + })} +
+ +
+ {caption &&
{caption}
} +
+ + diff --git a/src/components/mdx/BrowserSupport.astro b/src/components/mdx/BrowserSupport.astro index eab43a2..f587713 100644 --- a/src/components/mdx/BrowserSupport.astro +++ b/src/components/mdx/BrowserSupport.astro @@ -3,18 +3,36 @@ type Status = 'full' | 'partial' | 'flag' | 'none'; type Browser = 'chrome' | 'firefox' | 'safari' | 'edge' | 'node'; interface BrowserEntry { - browser: Browser; + browser?: Browser; + name?: string; version?: string; - status: Status; + status?: Status; + supported?: boolean; note?: string; } interface Props { feature: string; - data: BrowserEntry[]; + data?: BrowserEntry[]; + browsers?: BrowserEntry[]; + note?: string; } -const { feature, data } = Astro.props; +const { feature, data, browsers, note: topLevelNote } = Astro.props; + +const nameToKey: Record = { + chrome: 'chrome', firefox: 'firefox', safari: 'safari', edge: 'edge', + 'node.js': 'node', node: 'node', +}; + +const rawEntries = data ?? browsers ?? []; +const entries = rawEntries.map((e) => { + const browserKey: Browser = + e.browser ?? nameToKey[(e.name ?? '').toLowerCase()] ?? 'chrome'; + const status: Status = + e.status ?? (e.supported === false ? 'none' : 'full'); + return { browser: browserKey, version: e.version, status, note: e.note }; +}); const browserMeta: Record = { chrome: { @@ -50,7 +68,7 @@ const statusMeta: Record
{feature}
- {data.map((entry) => { + {entries.map((entry) => { const bm = browserMeta[entry.browser]; const sm = statusMeta[entry.status]; return ( @@ -88,6 +106,7 @@ const statusMeta: Record Flag None
+ {topLevelNote &&
{topLevelNote}
}
diff --git a/src/components/mdx/CSSLayerStack.astro b/src/components/mdx/CSSLayerStack.astro new file mode 100644 index 0000000..c8b2a0b --- /dev/null +++ b/src/components/mdx/CSSLayerStack.astro @@ -0,0 +1,355 @@ +--- +type LayerVariant = 'reset' | 'base' | 'tokens' | 'components' | 'utilities' | 'unlayered'; + +interface Layer { + /** Layer name exactly as written in @layer — use "(unlayered)" for styles outside any layer */ + name: string; + /** One-line description shown beneath the name */ + description?: string; + /** Representative rules to display inside the card */ + rules?: string[]; + /** Visual colour variant for the left accent bar */ + variant?: LayerVariant; + /** Add a glow highlight to this layer */ + highlight?: boolean; +} + +interface Props { + layers: Layer[]; + title?: string; + caption?: string; + /** Show a "↑ PRIORITY" sidebar on the right */ + showPriorityArrow?: boolean; +} + +const { layers, title, caption, showPriorityArrow = false } = Astro.props; + +// Input is ordered low-priority → high-priority (e.g. reset first, unlayered last). +// We reverse so the visual order is high-priority on top, low-priority on bottom. +const displayLayers = [...layers].reverse(); +--- + +
+ {title &&
{title}
} + +
+ {/* ── Priority indicator labels ───────────────────────────── */} + {showPriorityArrow && ( +
+ ↑ high priority + ↓ low priority +
+ )} + +
+ {/* ── Layer stack ────────────────────────────────────────── */} +
+ {displayLayers.map((layer) => { + const v = layer.variant ?? 'base'; + const isUnlayered = v === 'unlayered'; + return ( +
+ {/* Coloured left accent bar */} + + ); + })} +
+ + {/* ── Priority arrow sidebar ─────────────────────────────── */} + {showPriorityArrow && ( +
+ PRIORITY +
+ )} +
+
+ + {caption &&
{caption}
} +
+ + diff --git a/src/components/mdx/Interview.astro b/src/components/mdx/Interview.astro new file mode 100644 index 0000000..c06a91d --- /dev/null +++ b/src/components/mdx/Interview.astro @@ -0,0 +1,126 @@ +--- +interface Props { + scenario: string; + context?: string; + topic?: 'xss' | 'csp' | 'cors' | 'csrf' | 'cookies' | 'supply-chain' | 'sri' | 'general'; +} + +const { scenario, context, topic = 'general' } = Astro.props; + +const topicColors: Record = { + xss: { bg: 'var(--iv-xss-bg)', badge: 'var(--iv-xss-badge)' }, + csp: { bg: 'var(--iv-csp-bg)', badge: 'var(--iv-csp-badge)' }, + cors: { bg: 'var(--iv-cors-bg)', badge: 'var(--iv-cors-badge)' }, + csrf: { bg: 'var(--iv-csrf-bg)', badge: 'var(--iv-csrf-badge)' }, + cookies: { bg: 'var(--iv-cookies-bg)', badge: 'var(--iv-cookies-badge)' }, + 'supply-chain': { bg: 'var(--iv-sc-bg)', badge: 'var(--iv-sc-badge)' }, + sri: { bg: 'var(--iv-sri-bg)', badge: 'var(--iv-sri-badge)' }, + general: { bg: 'var(--iv-gen-bg)', badge: 'var(--iv-gen-badge)' }, +}; + +const colors = topicColors[topic]; +--- + +
+
+ {topic === 'supply-chain' ? 'supply chain' : topic} + {scenario} +
+ {context &&

{context}

} +
+ +
+
+ + diff --git a/src/components/mdx/Q.astro b/src/components/mdx/Q.astro new file mode 100644 index 0000000..c7cdb16 --- /dev/null +++ b/src/components/mdx/Q.astro @@ -0,0 +1,100 @@ +--- +interface Props { + label?: string; +} + +const { label = 'Interviewer' } = Astro.props; +--- + +
+
+ + + {label} + +
+
+ +
+
+ + diff --git a/src/components/mdx/SequenceDiagram.astro b/src/components/mdx/SequenceDiagram.astro index 0d42180..f5421d7 100644 --- a/src/components/mdx/SequenceDiagram.astro +++ b/src/components/mdx/SequenceDiagram.astro @@ -3,7 +3,8 @@ interface Actor { id: string; label: string; sublabel?: string; - icon?: 'browser' | 'server' | 'dns' | 'ca' | 'router' | 'client'; + icon?: 'browser' | 'server' | 'dns' | 'ca' | 'router' | 'client' | 'attacker' | 'cdn' | 'database'; + variant?: 'default' | 'malicious' | 'trusted'; } interface Message { @@ -11,7 +12,7 @@ interface Message { to: string; label: string; sublabel?: string; - type?: 'request' | 'response' | 'note' | 'error'; + type?: 'request' | 'response' | 'note' | 'error' | 'attack' | 'blocked'; highlight?: boolean; annotation?: string; } @@ -49,12 +50,16 @@ const TYPE_COLOR: Record = { response: '#22c55e', note: '#f59e0b', error: '#ef4444', + attack: '#ef4444', + blocked: '#ef4444', }; const TYPE_DASH: Record = { request: undefined, response: '6 3', note: '3 3', error: undefined, + attack: undefined, + blocked: '5 4', }; const msgColor = (msg: Message): string => @@ -67,7 +72,7 @@ const markerId = (msg: Message): string => `${uid}-${msg.highlight ? 'highlight' : (msg.type ?? 'request')}`; // All distinct marker IDs needed -const markerTypes = ['request', 'response', 'note', 'error', 'highlight'] as const; +const markerTypes = ['request', 'response', 'note', 'error', 'attack', 'blocked', 'highlight'] as const; type MarkerType = typeof markerTypes[number]; const markerColor = (t: MarkerType): string => @@ -164,6 +169,16 @@ const markerColor = (t: MarkerType): string => >{msg.sublabel} )} + {/* Blocked ✕ mark at arrowhead */} + {msg.type === 'blocked' && ( + + )} + {/* Side annotation */} {msg.annotation && ( {actors.map(actor => { const cx = actorX(actor.id); const bx = cx - ACTOR_BOX_W / 2; + const variantClass = + actor.variant === 'malicious' ? 'seq-actor-box seq-actor-malicious' + : actor.variant === 'trusted' ? 'seq-actor-box seq-actor-trusted' + : 'seq-actor-box'; return ( <> width={ACTOR_BOX_W} height={ACTOR_BOX_H - 4} rx="8" - class="seq-actor-box" + class={variantClass} /> stroke-width: 1.5; } + .seq-actor-malicious { + fill: rgba(239, 68, 68, 0.08); + stroke: rgba(239, 68, 68, 0.6); + stroke-width: 2; + } + + .seq-actor-trusted { + fill: rgba(34, 197, 94, 0.08); + stroke: rgba(34, 197, 94, 0.6); + stroke-width: 2; + } + + .seq-blocked-mark { + font-size: 13px; + font-weight: 700; + fill: #ef4444; + } + .seq-actor-label { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; diff --git a/src/components/mdx/SpecificityMeter.astro b/src/components/mdx/SpecificityMeter.astro new file mode 100644 index 0000000..5294210 --- /dev/null +++ b/src/components/mdx/SpecificityMeter.astro @@ -0,0 +1,341 @@ +--- +interface SelectorScore { + /** The CSS selector string to display and score */ + selector: string; + /** Optional human-readable label shown beneath the selector */ + label?: string; + /** Inline style contributions — style="" counts as 1-0-0-0 */ + inline: number; + /** ID selector count — #id counts as 0-1-0-0 */ + ids: number; + /** Class / attribute / pseudo-class count — .cls, [attr], :hover each 0-0-1-0 */ + classes: number; + /** Element / pseudo-element count — div, ::before each 0-0-0-1 */ + elements: number; + /** Visually emphasise this row (green ring) */ + highlight?: boolean; +} + +interface Props { + selectors: SelectorScore[]; + title?: string; + caption?: string; +} + +const { selectors, title, caption } = Astro.props; + +// ── Winner detection when exactly two selectors are compared ──────────────── +function beats(a: SelectorScore, b: SelectorScore): boolean { + if (a.inline !== b.inline) return a.inline > b.inline; + if (a.ids !== b.ids) return a.ids > b.ids; + if (a.classes !== b.classes) return a.classes > b.classes; + return a.elements > b.elements; +} + +const isComparison = selectors.length === 2; +let winIdx = -1; +if (isComparison) { + if (beats(selectors[0], selectors[1])) winIdx = 0; + else if (beats(selectors[1], selectors[0])) winIdx = 1; +} + +const SEGMENTS = [ + { + key: 'inline' as const, + label: 'inline', + title: 'Inline styles: style="" — 1-0-0-0 each', + cls: 'sm-seg--inline', + }, + { + key: 'ids' as const, + label: 'IDs', + title: 'ID selectors: #id — 0-1-0-0 each', + cls: 'sm-seg--ids', + }, + { + key: 'classes' as const, + label: 'classes', + title: 'Class / attribute / pseudo-class selectors — 0-0-1-0 each', + cls: 'sm-seg--classes', + }, + { + key: 'elements' as const, + label: 'elements', + title: 'Element / pseudo-element selectors — 0-0-0-1 each', + cls: 'sm-seg--elements', + }, +] as const; +--- + +
+ {title &&
{title}
} + +
+ {selectors.map((sel, idx) => { + const isWinner = isComparison && winIdx === idx; + const isLoser = isComparison && winIdx !== -1 && winIdx !== idx; + const isTied = isComparison && winIdx === -1; + const scoreStr = `${sel.inline}-${sel.ids}-${sel.classes}-${sel.elements}`; + return ( +
+ {/* ── Selector meta bar ─────────────────────────────── */} +
+
+ {sel.selector} + {sel.label && {sel.label}} +
+
+ + {scoreStr} + + {isWinner && ✓ Wins} + {isLoser && ✗ Loses} + {isTied && idx === 0 && = Tie} +
+
+ + {/* ── Score track ───────────────────────────────────── */} +
+ {SEGMENTS.map(seg => { + const count = sel[seg.key]; + return ( +
0 && 'sm-seg--on']} + title={seg.title} + > + {count} + {seg.label} +
+ ); + })} +
+
+ ); + })} +
+ + {caption &&
{caption}
} +
+ + diff --git a/src/components/mdx/TokenTree.astro b/src/components/mdx/TokenTree.astro new file mode 100644 index 0000000..77cd0bc --- /dev/null +++ b/src/components/mdx/TokenTree.astro @@ -0,0 +1,409 @@ +--- +type Tier = 'base' | 'semantic' | 'component'; + +interface TokenNode { + /** Dot-notation token name, e.g. "color.brand.primary" */ + name: string; + /** Raw value ("#0057d9") or alias reference ("{color.blue.600}") */ + value: string; + /** CSS custom property name, e.g. "--color-brand-primary" */ + cssVar?: string; + /** Which tier this token lives in */ + tier: Tier; + /** + * Names of tokens that this token directly aliases (its upstream sources). + * A semantic token `color.interactive.default` with children `["color.blue.600"]` + * means it resolves to the base token `color.blue.600`. + */ + children?: string[]; + /** + * When true, this token AND its full upstream chain are highlighted in accent colour. + */ + highlight?: boolean; +} + +interface Props { + tokens: TokenNode[]; + title?: string; + caption?: string; + /** Show the CSS custom property name inside each card */ + showCssVars?: boolean; +} + +const { tokens, title, caption, showCssVars = false } = Astro.props; + +// ── Layout constants ──────────────────────────────────────────────────────── +const SVG_W = 860; +const CARD_W = 220; +const CARD_H = showCssVars ? 76 : 58; +const ROW_GAP = 14; +const HEADER_H = 46; // Space for column heading text + rule +const BOT_PAD = 24; + +// Column centre x positions: base | semantic | component +const COL_X: Record = { + base: 130, + semantic: 430, + component: 730, +}; + +// ── Group tokens by tier ──────────────────────────────────────────────────── +const byTier: Record = { + base: tokens.filter(t => t.tier === 'base'), + semantic: tokens.filter(t => t.tier === 'semantic'), + component: tokens.filter(t => t.tier === 'component'), +}; + +// ── Compute card positions ────────────────────────────────────────────────── +interface Pos { cx: number; y: number } +const positions = new Map(); + +for (const tier of ['base', 'semantic', 'component'] as Tier[]) { + let yOffset = HEADER_H; + for (const t of byTier[tier]) { + positions.set(t.name, { cx: COL_X[tier], y: yOffset }); + yOffset += CARD_H + ROW_GAP; + } +} + +// ── Compute SVG height ────────────────────────────────────────────────────── +const maxBottom = Math.max( + ...Array.from(positions.values()).map(p => p.y + CARD_H), + HEADER_H + CARD_H, +); +const SVG_H = maxBottom + BOT_PAD; + +// ── Highlighted chain ─────────────────────────────────────────────────────── +// Walk upstream from any token with highlight:true and mark all ancestors. +const highlighted = new Set(); + +function traceUpstream(name: string): void { + if (highlighted.has(name)) return; + highlighted.add(name); + const t = tokens.find(t => t.name === name); + if (t?.children) t.children.forEach(traceUpstream); +} + +tokens.filter(t => t.highlight).forEach(t => traceUpstream(t.name)); + +// ── Connection lines ──────────────────────────────────────────────────────── +// For each semantic/component token, draw a bezier from each parent's right +// edge to this token's left edge. +interface Connection { + x1: number; y1: number; + x2: number; y2: number; + isHl: boolean; +} +const connections: Connection[] = []; + +for (const t of tokens) { + if (!t.children?.length) continue; + const toPos = positions.get(t.name); + if (!toPos) continue; + + for (const parentName of t.children) { + const fromPos = positions.get(parentName); + if (!fromPos) continue; + + const x1 = fromPos.cx + CARD_W / 2; // right edge of parent card + const y1 = fromPos.y + CARD_H / 2; // vertical centre of parent + const x2 = toPos.cx - CARD_W / 2; // left edge of this card + const y2 = toPos.y + CARD_H / 2; // vertical centre of this + + connections.push({ + x1, y1, x2, y2, + isHl: highlighted.has(t.name) && highlighted.has(parentName), + }); + } +} + +// ── Helpers ───────────────────────────────────────────────────────────────── +function looksLikeColor(v: string): boolean { + const t = v.trim(); + return /^#[0-9a-f]{3,8}$/i.test(t) || + t.startsWith('rgb') || + t.startsWith('hsl') || + t.startsWith('oklch') || + t.startsWith('color('); +} + +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max - 1) + '…' : s; +} + +const CONN_DEFAULT = 'rgba(99,102,241,0.18)'; +const CONN_HL = '#818cf8'; +const CONN_MID_X: Record = { + 'base-semantic': (COL_X.base + CARD_W / 2 + COL_X.semantic - CARD_W / 2) / 2, + 'semantic-component': (COL_X.semantic + CARD_W / 2 + COL_X.component - CARD_W / 2) / 2, +}; + +function midX(x1: number): number { + return x1 > 300 ? CONN_MID_X['semantic-component'] : CONN_MID_X['base-semantic']; +} + +const COL_LABELS: Array<{ tier: Tier; label: string }> = [ + { tier: 'base', label: 'Base Tokens' }, + { tier: 'semantic', label: 'Semantic Tokens' }, + { tier: 'component', label: 'Component Tokens' }, +]; + +const uid = `tt-${Math.random().toString(36).slice(2, 7)}`; +--- + +
+ {title &&
{title}
} + +
+ + + + + + + + + + + {/* ── Column header labels + divider lines ──────────────────────── */} + {COL_LABELS.map(({ tier, label }) => ( + <> + {label} + + + ))} + + {/* ── Connection lines (drawn behind cards) ─────────────────────── */} + {connections.map(conn => { + const mx = midX(conn.x1); + const d = `M ${conn.x1} ${conn.y1} C ${mx} ${conn.y1}, ${mx} ${conn.y2}, ${conn.x2} ${conn.y2}`; + return ( + + ); + })} + + {/* ── Token cards ───────────────────────────────────────────────── */} + {tokens.map(t => { + const pos = positions.get(t.name); + if (!pos) return null; + + const isHl = highlighted.has(t.name); + const hasColor = looksLikeColor(t.value); + const cardX = pos.cx - CARD_W / 2; + const cardY = pos.y; + + // Text x offsets: leave room for swatch if present + const nameX = hasColor ? cardX + 32 : cardX + 10; + const valueX = cardX + 10; + + // Y baselines within the card + const nameY = showCssVars ? cardY + 19 : cardY + 20; + const valueY = showCssVars ? cardY + 36 : cardY + 38; + const cssVarY = cardY + 58; + + return ( + <> + {/* Card rectangle */} + + + {/* Highlight chain left border bar */} + {isHl && ( + + )} + + {/* Colour swatch */} + {hasColor && ( + + )} + + {/* Token name */} + + {truncate(t.name, 26)} + + + {/* Token value */} + + {truncate(t.value, 28)} + + + {/* CSS variable (optional) */} + {showCssVars && t.cssVar && ( + + {truncate(t.cssVar, 30)} + + )} + + ); + })} + +
+ + {caption &&
{caption}
} +
+ + diff --git a/src/content/series/frontend-security/cookie-security.mdx b/src/content/series/frontend-security/cookie-security.mdx new file mode 100644 index 0000000..d09bfdb --- /dev/null +++ b/src/content/series/frontend-security/cookie-security.mdx @@ -0,0 +1,473 @@ +--- +title: "Cookie Defences: SameSite, Prefixes, and Fetch Metadata" +date: "May 2026" +pubDate: 2026-05-12 +category: Security +author: Honey Sharma +summary: Synchronizer tokens stop CSRF at the server. SameSite, __Host- prefix, and Fetch metadata stop it at the browser — before the forged request ever reaches business logic. This post covers every cookie security attribute and the Fetch metadata guard that completes the defence. +tags: [security, csrf, cookies, samesite, fetch-metadata, headers, frontend] +--- +import PacketDiagram from '../../../components/mdx/PacketDiagram.astro'; +import DecisionFlow from '../../../components/mdx/DecisionFlow.astro'; +import Comparison from '../../../components/mdx/Comparison.astro'; +import Callout from '../../../components/mdx/Callout.astro'; +import Lessons from '../../../components/mdx/Lessons.astro'; +import Lesson from '../../../components/mdx/Lesson.astro'; +import BrowserSupport from '../../../components/mdx/BrowserSupport.astro'; +import StateMachine from '../../../components/mdx/StateMachine.astro'; +import TreeDiagram from '../../../components/mdx/TreeDiagram.astro'; + +The previous post ended with CSRF tokens — the server-side layer that validates every state-changing request carries a secret only the legitimate client could know. + +But tokens are not the only layer. Cookies themselves can be hardened so the browser refuses to send them on cross-site requests in the first place. Request headers set by the browser — headers the attacker cannot forge — can tell your server whether a request originated from your own site or from somewhere else. Used together, these layers mean a CSRF attack never reaches the token validator, never reaches the business logic, and in many cases never leaves the browser at all. + +--- + +## The Three Cookie Security Flags + +Before covering SameSite, two other flags that every session cookie should carry: + +### `HttpOnly` + +```http +Set-Cookie: session=abc123; HttpOnly +``` + +JavaScript cannot read an `HttpOnly` cookie. `document.cookie` will not include it. A DevTools console cannot inspect its value. + +This is primarily an XSS mitigation. The canonical XSS payload steals `document.cookie`. If the session cookie is `HttpOnly`, there is nothing to steal — the script can read every other cookie, but not the one that matters. + + + An `HttpOnly` cookie is still sent with every request — the browser just does not expose it to JavaScript. The CSRF attack still works because the browser attaches the cookie automatically; the attacker's forged form post receives the cookie even though their JavaScript could not read it. HttpOnly prevents XSS cookie theft, not CSRF. + + +### `Secure` + +```http +Set-Cookie: session=abc123; Secure +``` + +The browser only sends a `Secure` cookie over HTTPS. It is never transmitted over HTTP, even if the user navigates to an `http://` URL of the same domain. + +This prevents the session cookie from being intercepted on an unencrypted connection — a real risk on public WiFi, where HTTP traffic is trivially observable. In 2026, HTTPS is essentially universal, but `Secure` remains mandatory — it is a hard guarantee, not a convention. + + + `Secure` controls transmission only. The cookie value is stored in the browser's cookie jar in plaintext. Other security mechanisms — rotating sessions, short TTLs, binding sessions to user-agent or IP fingerprints — protect the stored value against local access. + + +## SameSite: How the Browser Filters Cross-Site Requests + +`SameSite` tells the browser which cross-site contexts should include the cookie. It has three values, each with very different behaviour. + +### `SameSite=Strict` + +The browser never sends the cookie on any cross-site request — not navigations, not form submissions, not fetches. + +```http +Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly +``` + +If a user has a DevForum tab open and clicks a link to DevForum from another site, the browser sends them to DevForum without the session cookie. They arrive as a logged-out user and must authenticate before their session is attached. Only subsequent navigations *within* DevForum carry the cookie. + +This is complete CSRF protection — but the UX friction is real. Sharing a link to a DevForum post via email means every recipient arrives logged out, even if they have an active session. + +### `SameSite=Lax` + +The default in Chrome since 2020. `Lax` permits cookies on **top-level navigations using safe HTTP methods** (GET, HEAD) from cross-site contexts. It blocks cookies on all other cross-site requests — form POSTs, fetch calls, iframes, and non-navigational image loads. + +```http +Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly +``` + +A user clicks a link from Twitter to DevForum — the cookie is sent. The link takes them to a DevForum page, which looks up their session, and they arrive logged in. A forged form POST from `evil.com` — no cookie. + +`Lax` eliminates most CSRF vectors while preserving the "click a link, stay logged in" behaviour that `Strict` breaks. + +### `SameSite=None` + +The cookie is sent on all cross-site requests. Requires `Secure`. + +```http +Set-Cookie: session=abc123; SameSite=None; Secure; HttpOnly +``` + +This is the pre-2020 default and the setting required for third-party contexts: payment iframes, embedded widgets, cross-site SSO. A `SameSite=None` session cookie is fully vulnerable to CSRF — every defence from this post is necessary. + + + + + + + +## Why `SameSite=Lax` Is Not Sufficient Alone + +`Lax` handles the most common CSRF scenario. It does not handle all of them. + +**State-changing GET requests.** `Lax` permits cookies on cross-site top-level GET navigations. If your application performs a state change on a GET — an email confirmation link that directly activates the account, a "unsubscribe" link that performs the unsubscription in one click — an attacker can trigger it by embedding an `` or simply linking to it. Use POST for state changes. Always. + +**Subdomain attacks.** A script running on `blog.devforum.com` is considered "same-site" with `devforum.com`. If that subdomain is compromised or user-controllable, it can make same-site requests that `Lax` permits — with the session cookie attached. Subdomain isolation requires more than `SameSite`. + +**The two-minute Lax window.** Chrome applies a two-minute grace period: cookies without an explicit `SameSite` attribute are treated as `None` for two minutes after creation, then as `Lax` thereafter. This is for backward compatibility with single-sign-on flows. It means a freshly issued session cookie is briefly vulnerable to CSRF even in Chrome. + +**Legacy browsers.** Any browser that does not support `SameSite` ignores the attribute entirely. Enterprise environments often have older browser versions. Mobile browsers have inconsistent implementations. + +The conclusion: `SameSite=Lax` (or `Strict`) significantly reduces CSRF risk. It does not eliminate it. Layer it with synchronizer tokens and the mechanisms below. + +## Cookie Prefixes: `__Host-` and `__Secure-` + +Cookie prefixes are browser-enforced invariants — constraints the browser validates on every `Set-Cookie` header before accepting the cookie. + +### `__Secure-` + +A cookie named `__Secure-session` must be: +- Set over HTTPS (with the `Secure` attribute) + +```http +Set-Cookie: __Secure-session=abc123; Secure; HttpOnly; SameSite=Lax +``` + +If the server tries to set a `__Secure-` cookie without `Secure`, the browser rejects it silently. This prevents downgrade attacks where an attacker injects a cookie over HTTP to override the HTTPS cookie. + +### `__Host-` + +Stricter. A cookie named `__Host-session` must be: +- Set over HTTPS (with `Secure`) +- Not have a `Domain` attribute +- Have `Path=/` + +```http +Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Strict; Path=/ +``` + +By omitting `Domain`, the cookie is bound to the exact host that set it — `devforum.com`. It cannot be set by `blog.devforum.com` and it is not sent to `blog.devforum.com`. The subdomain attack vector that bypasses `SameSite=Lax` is eliminated. + + + `__Host-` is the strictest prefix and closes the subdomain cookie injection vector completely. There is almost no downside for session cookies — the restrictions it enforces (`Secure`, no `Domain`, `Path=/`) are what your session cookie should have anyway. Use it by default. + + +## The Hardened Cookie Header + +Combining everything so far, a production session cookie for DevForum: + + + +Setting this in Express: + +```js +res.cookie('__Host-session', sessionToken, { + httpOnly: true, + secure: true, + sameSite: 'strict', + path: '/', + maxAge: 86400 * 1000, // 24 hours in milliseconds + // No domain: — required for __Host- prefix +}); +``` + + + Many Express cookie libraries default to setting `domain: req.hostname`. With `__Host-`, any `Domain` attribute causes the browser to reject the cookie silently. Explicitly omit `domain`, or set it to `undefined`. Test with DevTools — if the cookie does not appear after `Set-Cookie`, the browser rejected it. + + +## Cookie Scoping: Domain and Path + +Without any `Domain` attribute, a cookie is bound to the exact host that set it. Adding `Domain` widens the scope: + +```http +Set-Cookie: pref=dark; Domain=devforum.com # sent to devforum.com AND all subdomains +Set-Cookie: pref=dark # sent only to the exact host that set it +``` + +`Domain=devforum.com` means the cookie is sent to `api.devforum.com`, `blog.devforum.com`, and any other subdomain. If any of those subdomains is compromised, the cookie is exposed. + + + +For session cookies, omit `Domain` — or use `__Host-`, which enforces the omission. For preference cookies that genuinely need to be shared across subdomains, `Domain` is appropriate, but those cookies should never carry security-sensitive values. + +`Path` scopes the cookie to a URL prefix. `Path=/admin` means the cookie is only sent on requests to `/admin/...`. This is a convenience feature, not a security one — JavaScript on the same origin can still read cookies regardless of `Path`. Do not use `Path` as a security boundary. + +## Origin and Referer Header Verification + +Before Fetch metadata (covered next), the standard secondary check was verifying the `Origin` or `Referer` request header. + +The browser sets `Origin` on cross-origin requests and on same-site POST requests. An attacker on `evil.com` cannot forge this header — it is set by the browser, not by JavaScript. + +```js +function verifyOrigin(req, res, next) { + const origin = req.headers.origin; + const referer = req.headers.referer; + + // Origin is present on most CSRF-relevant requests + if (origin) { + const url = new URL(origin); + if (url.hostname !== 'devforum.com') { + return res.status(403).json({ error: 'Forbidden origin' }); + } + return next(); + } + + // Fallback to Referer for browsers that omit Origin on same-origin POSTs + if (referer) { + const url = new URL(referer); + if (url.hostname !== 'devforum.com') { + return res.status(403).json({ error: 'Forbidden referer' }); + } + return next(); + } + + // Neither header present — reject (conservative stance) + return res.status(403).json({ error: 'Missing origin headers' }); +} +``` + + + Some browsers, browser extensions, and privacy tools strip the `Referer` header. Some request types do not include `Origin`. Using either as your only CSRF defence means legitimate requests may be rejected for non-obvious reasons. Treat these as a secondary check that supplements token validation, not replaces it. + + +## Fetch Metadata Headers + +Fetch metadata is a set of browser-generated request headers that describe the context of every outgoing request. They are set by the browser and cannot be forged by web page JavaScript. + +The most important one is `Sec-Fetch-Site`. It tells the server where the request originated relative to the target: + +| `Sec-Fetch-Site` value | Meaning | +|------------------------|---------| +| `same-origin` | Request came from the same scheme + host + port (your own JavaScript) | +| `same-site` | Request came from the same registrable domain but possibly a different subdomain | +| `cross-site` | Request came from a completely different site | +| `none` | Direct user navigation — typed URL, bookmark, opened link | + +A request from DevForum's own JavaScript has `Sec-Fetch-Site: same-origin`. A forged form POST from `evil.com` has `Sec-Fetch-Site: cross-site`. That single header is enough to identify most CSRF attempts. + +Two companion headers add more detail when you need it: + +| Header | What it says | +|--------|-------------| +| `Sec-Fetch-Mode` | How the fetch was initiated: `navigate`, `cors`, `no-cors`, `same-origin`, `websocket` | +| `Sec-Fetch-Dest` | The destination of the request: `document`, `script`, `image`, `empty`, etc. | + +In practice, `Sec-Fetch-Site` does the heavy CSRF lifting. `Sec-Fetch-Mode` is used to distinguish a user clicking a link (`navigate`) from a programmatic cross-site `fetch()` call (`cors` or `no-cors`). Here is what a real attack looks like compared to a legitimate cross-site navigation: + +A CSRF form post from `evil.com`: +```http +Sec-Fetch-Site: cross-site +Sec-Fetch-Mode: navigate +Sec-Fetch-Dest: document +``` + +A user clicking a legitimate link from another site: +```http +Sec-Fetch-Site: cross-site +Sec-Fetch-Mode: navigate +Sec-Fetch-Dest: document +``` + +These look identical — which is why `Sec-Fetch-Site` alone isn't always sufficient for navigation-triggered state changes. The resource isolation policy below combines both headers to distinguish them. + +The key insight: `Sec-Fetch-Site: cross-site` on a state-changing request (non-navigate mode, or navigate with a non-safe method) is a strong signal that the request did not originate from your application. + +### The Resource Isolation Policy + +Google's [Fetch Metadata guide](https://web.dev/fetch-metadata/) defines a server-side guard that uses these headers to reject illegitimate cross-site requests before they reach business logic: + +```js +function resourceIsolationPolicy(req, res, next) { + const site = req.headers['sec-fetch-site']; + const mode = req.headers['sec-fetch-mode']; + const dest = req.headers['sec-fetch-dest']; + + // Allow requests from browsers that don't support Fetch metadata + // (site header absent — fall through to CSRF token validation) + if (!site) return next(); + + // Allow same-origin requests unconditionally + if (site === 'same-origin') return next(); + + // Allow browser-initiated navigations (user clicked a link) + if (mode === 'navigate' && req.method === 'GET') return next(); + + // Block all other cross-site requests to protected endpoints + if (site === 'cross-site') { + return res.status(403).json({ error: 'Cross-site request blocked by resource isolation policy' }); + } + + next(); +} +``` + + + + + +## Defence-in-Depth: All Layers Combined + +Each layer stops CSRF at a different point in the request lifecycle. No single layer covers every scenario. All layers together leave an attacker with no viable path. + + + +The minimum viable combination: +1. **Synchronizer tokens** (or double-submit with `__Host-`) on every state-changing endpoint +2. **`SameSite=Lax`** on the session cookie (already the browser default since 2020 — set it explicitly regardless) +3. **`__Host-` prefix** on the session cookie + +Add if you want the strongest posture: +4. **`SameSite=Strict`** instead of `Lax` (eliminates the cross-site navigation session, worth it for high-security apps) +5. **Fetch metadata resource isolation policy** as a pre-validation filter + + + + XSS cannot steal what JavaScript cannot read. There is no legitimate reason for JavaScript to access the session cookie. If a feature requires it, redesign the feature to use a separate readable token with limited scope. + + + HTTPS is table stakes in 2026. Secure ensures the cookie is never accidentally transmitted over HTTP — even if the user manually types http:// or a redirect momentarily lands on an insecure URL. + + + SameSite alone does not prevent subdomain cookie injection. __Host- does. The restrictions it enforces — Secure, no Domain, Path=/ — are what a session cookie should have regardless. Adopt it as the default. + + + Chrome defaults to Lax for cookies without an explicit SameSite, but only after a two-minute window where the cookie behaves as None. Set SameSite explicitly on every Set-Cookie. Never rely on browser defaults for security-relevant cookie behaviour. + + + The resource isolation policy blocks most CSRF before tokens are even checked — reducing latency and server load on attack traffic. But it misses Safari and old browsers. Tokens remain the primary defence. Fetch metadata is the filter before the gate. + + + Path scopes when a cookie is sent — but JavaScript on the same origin can read all cookies regardless of Path. Do not design a feature where security depends on a cookie being invisible to scripts at a different path. + + diff --git a/src/content/series/frontend-security/cors-deep-dive.mdx b/src/content/series/frontend-security/cors-deep-dive.mdx new file mode 100644 index 0000000..3b6cfa1 --- /dev/null +++ b/src/content/series/frontend-security/cors-deep-dive.mdx @@ -0,0 +1,316 @@ +--- +title: "CORS: Beyond 'Just Add the Header'" +date: "Apr 2026" +pubDate: 2026-04-28 +category: Security +author: Honey Sharma +summary: Most CORS fixes are copied and pasted. This post builds the Same-Origin Policy from first principles, traces the full preflight, and shows the wildcard trap, Vary header problem, and CORS misconfigurations that ship to production. +tags: [security, cors, same-origin-policy, preflight, http, headers, frontend] +--- +import SequenceDiagram from '../../../components/mdx/SequenceDiagram.astro'; +import PacketDiagram from '../../../components/mdx/PacketDiagram.astro'; +import Comparison from '../../../components/mdx/Comparison.astro'; +import DecisionFlow from '../../../components/mdx/DecisionFlow.astro'; +import Callout from '../../../components/mdx/Callout.astro'; +import Lessons from '../../../components/mdx/Lessons.astro'; +import Lesson from '../../../components/mdx/Lesson.astro'; + +DevForum's new frontend launches on `app.devforum.com`. The backend API stays on `api.devforum.com`. The developer opens the browser, loads the dashboard, and watches the network tab. Every API request returns 200. The JavaScript receives nothing. The console says: + +``` +Access to fetch at 'https://api.devforum.com/v1/posts' from origin +'https://app.devforum.com' has been blocked by CORS policy: No +'Access-Control-Allow-Origin' header is present on the requested resource. +``` + +Most developers respond by adding `Access-Control-Allow-Origin: *` to the API and moving on. This post is about understanding what you just did — and when that answer is the wrong one. + +--- + +## The Same-Origin Policy, From First Principles + +Before CORS can make sense, the restriction it relaxes needs to be clear. + +### What is an "origin"? + +An origin is a triple: **scheme + host + port**. All three must match for two URLs to share an origin. The path, query string, and fragment are irrelevant. + +``` +Base URL: https://devforum.com + +https://devforum.com/posts → Same origin (path doesn't count) +http://devforum.com → Different (scheme: https ≠ http) +https://app.devforum.com → Different (host: subdomain ≠ apex) +https://api.devforum.com → Different (host: different subdomain) +https://devforum.com:8080 → Different (port: 443 ≠ 8080) +https://devforum.com:443 → Same origin (443 is the default HTTPS port) +``` + + + `app.devforum.com` and `devforum.com` are different origins. They cannot read each other's responses, share cookies across origins, or access each other's localStorage — not without explicit configuration. This surprises most developers the first time they split a frontend and API onto separate subdomains. + + +### What SOP restricts — and what it doesn't + +The Same-Origin Policy restricts **JavaScript reading cross-origin responses**. It does not restrict much else: + +| Action | Cross-origin allowed? | +|--------|----------------------| +| Navigate (``, `window.location`) | Yes | +| Form submission (`
`) | Yes — response blocked, not request | +| Embed resources (``, ` +``` + + + If the nonce is hardcoded in a config file, environment variable, or generated once at server startup, it is a permanent value — not a nonce. An attacker who observes the nonce value once can use it in every injected script. Nonces must be generated fresh per request using a cryptographically secure source. + + +--- + +## Hash-Based CSP + +Instead of a per-request token, you can allowlist a specific script by its SHA-256 content hash. The browser computes the hash of the inline script and checks it against the policy. + +Generate the hash for a specific inline script: + + + +If you don't have `openssl` available locally, paste the script URL into [srihash.org](https://www.srihash.org) — it fetches the file and generates the hash for you. + +The resulting CSP directive: + +```http +Content-Security-Policy: script-src 'sha256-K7fGRyPFoB+5wNHikKF2gqrOaSS3OkR9N8dKuivFm4c=' +``` + +The browser will only execute inline scripts whose content hashes to this exact value. Any character change — including a trailing space or newline — produces a different hash and blocks execution. + + + Use hashes for static bootstrap scripts, analytics initialisation, or feature flag snippets that are version-controlled and change infrequently. For anything that varies with user session data or personalisation, nonces are the right tool — a hash of dynamic content would need to be recalculated on every request, which is effectively a nonce. + + +## Strict CSP vs Domain Allowlists + +Most CSP tutorials tell you to list the domains your scripts come from: + +```http +Content-Security-Policy: script-src 'self' cdn.jsdelivr.net ajax.googleapis.com +``` + +This is almost always bypassable. Here's why: + +**JSONP endpoints on allowlisted domains.** JSONP turns an arbitrary callback parameter into a script execution primitive. If any endpoint on `ajax.googleapis.com` accepts a `callback` parameter, this executes attacker-controlled code: + +```html + +``` + +The domain is allowlisted. The script loads. The callback executes. + +**User-uploaded content on CDNs.** If `cdn.jsdelivr.net` hosts any file an attacker can upload, they control a script at an allowlisted URL. + +**`'strict-dynamic'`** solves this. When combined with a nonce, it means: scripts that have a valid nonce (trusted by the developer) may load further scripts dynamically. Scripts without a nonce may not execute — even from allowlisted domains. Domain allowlists are ignored when `strict-dynamic` is present. + +```http +Content-Security-Policy: script-src 'nonce-r4nd0mXYZ' 'strict-dynamic' +``` + +Your React or Vite bundle loads via a nonce-bearing ` +``` + +The form submits the moment the page loads. The user's browser sends a POST to `devforum.com/account/delete`. It attaches the DevForum session cookie automatically — the same cookie it attaches to every request to `devforum.com`. The server receives a valid session token, confirms the account deletion request, and returns 200. + +The user never clicked anything on DevForum. The attacker's site never received the response. The account is gone. + +This is Cross-Site Request Forgery. The server did exactly what it was told — by the attacker. + +## Why the Browser Does This + +Browsers were designed to attach cookies to requests unconditionally. When `devforum.com` set a session cookie, the browser committed to sending it with every future request to `devforum.com` — regardless of which page initiated the request. + +This behaviour is fundamental to how the web works. Without it, clicking a link from a Google search result to a site where you're already logged in would require you to log in again. Every navigation would lose session state. + +CSRF is not a browser bug. It is the intentional default behaviour of cookies, used against you. + +## Why CORS Doesn't Save You + +The previous post covered how CORS blocks cross-origin *response reads*. Developers often assume that same protection extends to cross-site requests. It does not. + +The browser still *sends* the request. The form POST to `devforum.com/account/delete` goes through. The server processes it. The state change happens. CORS only prevents `evil.com`'s JavaScript from reading the response — but the damage was done before the response arrived. + + + SOP and CORS operate on responses. A cross-origin form POST reaches its target server before any CORS check runs. By the time the browser decides whether `evil.com` can read the response, the account is already deleted. CSRF protection must operate on the server side, at request validation time. + + +## Why CSRF Still Works in 2026 + +Modern browsers defaulted `SameSite` to `Lax` starting in 2020, which blocks many CSRF vectors. (`SameSite` is a cookie attribute that controls when the browser includes cookies on cross-site requests — it's covered fully in the next post. For now: `Lax` means cookies are sent on top-level navigations but not on cross-site form POSTs or `fetch()` calls.) But the attack is not solved. It survives in several real scenarios: + +**Subdomain attacks.** If an attacker can run code on any subdomain of your domain, they can set cookies that scope to your apex domain — and those cookies are sent on same-site cross-origin requests that `SameSite=Lax` permits. + +**Top-level GET navigations.** `SameSite=Lax` permits cookies on top-level navigations using safe methods. If a state-changing action can be triggered via a GET request (search, export, confirm via link), Lax does not protect it. + +**Legacy browsers.** `SameSite` is not universally supported. Any user on an older browser — common in enterprise environments — receives no protection from browser-default SameSite behaviour. + +**Mobile WebViews.** Some WebView implementations handle cookies differently from browser behaviour. `SameSite` semantics vary. + +**Explicitly `SameSite=None` cookies.** Cookies set by legacy SSO systems, third-party integrations, or payment providers are often `SameSite=None` by requirement. They are fully vulnerable to CSRF. + + + SameSite is one layer of defence, not a complete solution. The right approach is defence-in-depth: synchronizer tokens or double-submit cookies on the server, SameSite on the cookie, and Fetch metadata on the request. The next post covers the cookie and browser layers in full. + + +## The Attack in Full + +