From 92e1330d4bf947c3cfbc5df4da79d323a006a6bd Mon Sep 17 00:00:00 2001 From: Pat Altimore <17440249+PatAltimore@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:25:14 -0700 Subject: [PATCH] Fix security issues found in review - Add URL scheme allowlist (safeUrl) to block javascript:/data: XSS - Escape unescaped year interpolations - Add SRI hashes + crossorigin to CDN scripts - Add CSP and hardening headers to SWA config - Validate slugs against path traversal in generator Co-Authored-By: Claude Opus 4.8 --- code_generator/checkpointer.py | 20 ++++++++++++++++++-- public/index.html | 8 ++++---- public/js/app.js | 33 ++++++++++++++++++++++++--------- public/staticwebapp.config.json | 5 ++++- 4 files changed, 50 insertions(+), 16 deletions(-) diff --git a/code_generator/checkpointer.py b/code_generator/checkpointer.py index 29249f4..c362594 100644 --- a/code_generator/checkpointer.py +++ b/code_generator/checkpointer.py @@ -1,12 +1,28 @@ +import re from pathlib import Path +# Slugs are used to build filesystem paths and originate from config that may be +# LLM-generated from an untrusted GitHub repo tree, so restrict them to a safe +# character set to prevent path traversal (e.g. a "../" slug escaping the root). +_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") + + +def _validate_slug(slug: str) -> str: + if not isinstance(slug, str) or not _SLUG_RE.match(slug): + raise ValueError(f"Unsafe slug: {slug!r}") + return slug + class Checkpointer: def __init__(self, output_dir: str): - self.root = Path(output_dir) + self.root = Path(output_dir).resolve() def path_for(self, program_slug: str, file_slug: str) -> Path: - return self.root / program_slug / f"{file_slug}.md" + path = (self.root / _validate_slug(program_slug) / f"{_validate_slug(file_slug)}.md").resolve() + # Defense in depth: ensure the resolved path stays within the root. + if self.root not in path.parents: + raise ValueError(f"Path escapes output root: {path}") + return path def is_done(self, program_slug: str, file_slug: str) -> bool: return self.path_for(program_slug, file_slug).exists() diff --git a/public/index.html b/public/index.html index 2a281ea..a0d221f 100644 --- a/public/index.html +++ b/public/index.html @@ -14,10 +14,10 @@
- - - - + + + + diff --git a/public/js/app.js b/public/js/app.js index 346b29e..c92b3ce 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -360,12 +360,12 @@ function renderEnhancement(enh) { const imageHtml = hasImage ? `
- ${escapeAttr(enh.title)} + ${escapeAttr(enh.title)} ${enh.image_caption ? `

${escapeHtml(enh.image_caption)}${commonsUrl(enh.image_url) ? ` Wikimedia Commons` : ''}

` : ''}
` : ''; const wikiHtml = enh.wikipedia_url - ? `Wikipedia ↗` + ? `Wikipedia ↗` : ''; return ` @@ -396,6 +396,21 @@ function escapeAttr(str) { return String(str || '').replace(/"/g, '"').replace(/'/g, '''); } +// Validate a URL before it is placed in an href/src attribute. Content here is +// LLM-generated from untrusted GitHub/Wikipedia sources, so a `javascript:` (or +// `data:`/`vbscript:`) URL could otherwise become stored XSS. Returns the URL +// unchanged when its scheme is allowed (or it has no scheme — i.e. a relative +// or hash link), and '' when the scheme is disallowed. +function safeUrl(url) { + const raw = String(url || ''); + // Browsers ignore control chars and whitespace when resolving a scheme, so + // strip them before testing to defeat obfuscation like "java\tscript:". + const probe = raw.replace(/[\u0000-\u0020]+/g, '').toLowerCase(); + const scheme = probe.match(/^([a-z][a-z0-9+.-]*):/); + if (scheme && !['http', 'https', 'mailto'].includes(scheme[1])) return ''; + return raw.trim(); +} + function commonsUrl(url) { if (!url) return null; const m = url.match(/\/thumb\/[0-9a-f]\/[0-9a-f]{2}\/(.+?)\/\d+px-/); @@ -434,7 +449,7 @@ function renderShelf(catalog) {
${escapeHtml(p.author)} · - ${p.year} + ${escapeHtml(p.year)} · ${(p.files || []).length} files
@@ -577,7 +592,7 @@ function renderProgramPage(program) { const introImageHtml = program.image_url ? `
- ${escapeAttr(program.title)} + ${escapeAttr(program.title)} ${program.image_caption ? `
${escapeHtml(program.image_caption)}${commonsUrl(program.image_url) ? ` Wikimedia Commons` : ''}
` : ''}
` : ''; @@ -587,13 +602,13 @@ function renderProgramPage(program) {

${escapeHtml(program.title)}

- + ${introImageHtml} ${introHtml}
${highlightsHtml}
${treeHtml}
- ${program.github_url ? `View source on GitHub ↗` : ''} + ${program.github_url ? `View source on GitHub ↗` : ''}
`; } @@ -626,7 +641,7 @@ function renderHeader(opts = {}) { `; } if (githubUrl) { - right += `GitHub ↗`; + right += `GitHub ↗`; } return ` @@ -807,7 +822,7 @@ function renderReader(meta, body, program) { const nextFile = currentIdx >= 0 && currentIdx < files.length - 1 ? files[currentIdx + 1] : null; const summaryHtml = (meta.summary || []).map(s => - `
  • ${escapeHtml(s.point)}${s.link ? ` ${escapeHtml(s.link_label || 'Wikipedia')}` : ''}
  • ` + `
  • ${escapeHtml(s.point)}${s.link ? ` ${escapeHtml(s.link_label || 'Wikipedia')}` : ''}
  • ` ).join(''); const codeHtml = renderCodeWithEnhancements(body, meta.enhancements || [], meta.language); @@ -825,7 +840,7 @@ function renderReader(meta, body, program) {

    ${escapeHtml(meta.title)}

    - +

    ${escapeHtml(meta.description)}

    ${summaryHtml ? `
      ${summaryHtml}
    ` : ''} ${mobileHtml} diff --git a/public/staticwebapp.config.json b/public/staticwebapp.config.json index 2dd8b78..69d99fb 100644 --- a/public/staticwebapp.config.json +++ b/public/staticwebapp.config.json @@ -9,7 +9,10 @@ }, "mimeTypes": { ".md": "text/plain" }, "globalHeaders": { - "Referrer-Policy": "no-referrer-when-downgrade" + "Referrer-Policy": "no-referrer-when-downgrade", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.wikimedia.org; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" }, "headers": [ {