diff --git a/AGENTS.md b/AGENTS.md index 469958c..f91041a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,10 +55,13 @@ make locust-headless # Locust in headless CLI mode (for CI) 1. **Emoji shortcodes** → Unicode emoji (`:fire:` → 🔥, 80+ shortcodes) 2. **Markdown parse** → via `marked` with GFM tables, line breaks -3. **Code syntax highlight** → `highlight.js` on all `
` blocks
-4. **Image URLs** → standalone `.jpg/.png/.gif` URLs become `
` tags
-5. **YouTube URLs** → `youtube.com/watch?v=` or `youtu.be/` become embedded iframe players
-6. **All URLs** → become `` links with `target="_blank"` and `rel="noopener"`
+3. **Sanitize** → `DOMPurify.sanitize` strips script/event-handler/iframe/`javascript:` payloads from the marked output
+4. **Code syntax highlight** → `highlight.js` on all `` blocks
+5. **Image URLs** → standalone `.jpg/.png/.gif` URLs become `
` tags
+6. **YouTube URLs** → `youtube.com/watch?v=` or `youtu.be/` become embedded iframe players
+7. **All URLs** → become `` links with `target="_blank"` and `rel="noopener"`
+
+**Sanitization is the XSS control.** Content is rendered client-side from `element.textContent`, so Jinja autoescaping does not protect it. `DOMPurify.sanitize` (vendored at `static/vendor/purify.min.js`, loaded `defer` in `base.html`) runs on the raw `marked` output before `processMedia` injects the trusted YouTube iframes, so user payloads are removed while our embeds survive. It is fail-closed: `render()` throws if `DOMPurify` is missing rather than emitting unsanitized HTML — never relax this into a `typeof` skip.
**Code blocks are protected** - `NodeIterator` skips `CODE`, `PRE`, `SCRIPT`, `STYLE` elements during URL/media processing, so source code in markdown code blocks is never touched.
@@ -71,6 +74,7 @@ Loaded via `
+
@@ -563,19 +567,11 @@ All tests must pass. Tests stop at first failure (`-x`).
### Step 7: Run full suite again (only if asked by user)
```bash
-hawk .
make test
make test-headed # visual confirmation
```
-### Step 8: Visual verification (if UI changed)
-
-```bash
-falcon take --output /tmp/verify.png
-falcon describe /tmp/verify.png
-```
-
-### Step 9: Document
+### Step 8: Document
- Update `AGENTS.md` if new conventions introduced
- Update `README.md` if new routes, config, or dependencies added
diff --git a/devplacepy/seo.py b/devplacepy/seo.py
index 81dee88..abd8876 100644
--- a/devplacepy/seo.py
+++ b/devplacepy/seo.py
@@ -151,6 +151,17 @@ def software_source_code_schema(gist, base_url):
}
+def _json_ld_dumps(payload):
+ raw = json.dumps(payload, ensure_ascii=False)
+ return (
+ raw.replace("<", "\\u003c")
+ .replace(">", "\\u003e")
+ .replace("&", "\\u0026")
+ .replace("
", "\\u2028")
+ .replace("
", "\\u2029")
+ )
+
+
def combine(schemas):
if not schemas:
return None
@@ -161,9 +172,12 @@ def combine(schemas):
cleaned.append(s)
if not cleaned:
return None
- if len(cleaned) == 1:
- return json.dumps({"@context": "https://schema.org", **cleaned[0]}, ensure_ascii=False)
- return json.dumps({"@context": "https://schema.org", "@graph": cleaned}, ensure_ascii=False)
+ payload = (
+ {"@context": "https://schema.org", **cleaned[0]}
+ if len(cleaned) == 1
+ else {"@context": "https://schema.org", "@graph": cleaned}
+ )
+ return _json_ld_dumps(payload)
DEFAULT_OG_IMAGE = "/static/og-default.png"
diff --git a/devplacepy/static/js/ContentRenderer.js b/devplacepy/static/js/ContentRenderer.js
index 39503df..8f8c56a 100644
--- a/devplacepy/static/js/ContentRenderer.js
+++ b/devplacepy/static/js/ContentRenderer.js
@@ -51,6 +51,11 @@ export class ContentRenderer {
html = "" + text.replace(/\n/g, "
") + "
";
}
+ if (typeof DOMPurify === "undefined") {
+ throw new Error("DOMPurify not loaded; refusing to render untrusted HTML");
+ }
+ html = DOMPurify.sanitize(html);
+
html = this.processMedia(html);
return html;
diff --git a/devplacepy/templates/base.html b/devplacepy/templates/base.html
index 2a1ad2c..b4c476e 100644
--- a/devplacepy/templates/base.html
+++ b/devplacepy/templates/base.html
@@ -167,6 +167,7 @@
+
{% block extra_js %}{% endblock %}