feat(demo): refuse a data: URI in any write body (DM25) #229

Merged
whitlocktech merged 2 commits from feature/demo-data-uri-lock into main 2026-10-27 20:28:30 -05:00
Member

What & why

Uploads are locked hard on the demo (DM4): every multipart/* request is refused. An image pasted into rich text doesn't use multipart, though. It is saved as a data:image/...;base64,... string inside the JSON body, so a visitor could still store images. The org lead decided (DM25, 2026-10-27): refuse it on every write, for every data: URI.

refuseDataUri (server/src/demo/locks.js) is mounted next to refuseMultipart in app.js, and only when DEMO_MODE=true. It walks every string in a POST/PUT/PATCH/DELETE JSON body (iteratively, so deep nesting can't overflow the stack) and answers the usual 403 DEMO_LOCKED on a match. Nothing is listed, so posts, wiki, events, forum posts, the hero draft, settings and module routes are all covered, including fields added later.

What matches: data: that isn't the end of a longer word, followed by ,, ;, or type/subtype and then a comma. Before testing, the string is read the way a browser reads an attribute: HTML character references are decoded (&#100;, &#x64;, &colon;, &Tab;, &NewLine;) and tabs and newlines removed. So &#100;ata:image/..., data&colon;..., da<newline>ta:..., data: image/png;... and CSS url(data:...) are all refused. metadata: none, Data: none, yet and https://x/data:y pass. Reads are never checked.

No client change: the editor's save gets the demo sentence through the existing toast (DemoToast) and Android snackbar (DM19).

Note: off the demo, sanitizeHtml already drops data: from <img> on news, wiki and forum bodies, but rich-text blocks and other fields only pass through DOMPurify at render, which keeps it. This rule is the one that covers them all on the demo.

Docs: RunicGateway/docs#386 (DEMO_MODE.md §3.3 + DM25, BACKEND_DESIGN.md §5.1).

How it was tested

  • test/demoLocks.test.js: a data: URI refused on five routes across all four write methods, including deep nesting and a JSON-string setting; 12 shapes a browser would load (case, svg, text/html, data:,, data:;base64, a space after the colon, CSS url(), decimal/hex/named references, a split newline, a tab); prose and URLs that must pass; non-string bodies; a read carrying one in the query.
  • test/demoApp.test.js: through the real app, a pasted image on POST /admin/posts is refused before its auth check (403, not 401).
  • Full server suite with MODULES_DIR pointed away: 2317 tests, 0 failures.
  • No route changes, so the OpenAPI spec and route manifest are unchanged.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code (Claude Opus 5.5). I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By / Assisted-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY

## What & why Uploads are locked hard on the demo (DM4): every `multipart/*` request is refused. An image **pasted into rich text** doesn't use multipart, though. It is saved as a `data:image/...;base64,...` string inside the JSON body, so a visitor could still store images. The org lead decided (DM25, 2026-10-27): refuse it **on every write**, for **every `data:` URI**. `refuseDataUri` (`server/src/demo/locks.js`) is mounted next to `refuseMultipart` in `app.js`, and only when `DEMO_MODE=true`. It walks every string in a `POST`/`PUT`/`PATCH`/`DELETE` JSON body (iteratively, so deep nesting can't overflow the stack) and answers the usual `403 DEMO_LOCKED` on a match. Nothing is listed, so posts, wiki, events, forum posts, the hero draft, settings and module routes are all covered, including fields added later. **What matches:** `data:` that isn't the end of a longer word, followed by `,`, `;`, or `type/subtype` and then a comma. Before testing, the string is read the way a browser reads an attribute: HTML character references are decoded (`&#100;`, `&#x64;`, `&colon;`, `&Tab;`, `&NewLine;`) and tabs and newlines removed. So `&#100;ata:image/...`, `data&colon;...`, `da<newline>ta:...`, `data: image/png;...` and CSS `url(data:...)` are all refused. `metadata: none`, `Data: none, yet` and `https://x/data:y` pass. Reads are never checked. No client change: the editor's save gets the demo sentence through the existing toast (DemoToast) and Android snackbar (DM19). Note: off the demo, `sanitizeHtml` already drops `data:` from `<img>` on news, wiki and forum bodies, but rich-text blocks and other fields only pass through DOMPurify at render, which keeps it. This rule is the one that covers them all on the demo. Docs: **RunicGateway/docs#386** (DEMO_MODE.md §3.3 + DM25, BACKEND_DESIGN.md §5.1). ## How it was tested - `test/demoLocks.test.js`: a data: URI refused on five routes across all four write methods, including deep nesting and a JSON-string setting; 12 shapes a browser would load (case, svg, `text/html`, `data:,`, `data:;base64`, a space after the colon, CSS `url()`, decimal/hex/named references, a split newline, a tab); prose and URLs that must pass; non-string bodies; a read carrying one in the query. - `test/demoApp.test.js`: through the real app, a pasted image on `POST /admin/posts` is refused **before** its auth check (403, not 401). - Full server suite with `MODULES_DIR` pointed away: **2317 tests, 0 failures**. - No route changes, so the OpenAPI spec and route manifest are unchanged. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Claude Opus 5.5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
wtclaude added 1 commit 2026-10-27 13:14:21 -05:00
feat(demo): refuse a data: URI in any write body (DM25)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 45s
PR Checks / client-build (pull_request) Successful in 1m2s
PR Checks / server-tests (pull_request) Successful in 6m4s
61feca50a8
Uploads are locked hard on the demo, but an image pasted into rich text
does not use multipart: it is saved as a data: URI inside the JSON body.
refuseDataUri, mounted beside refuseMultipart, refuses any POST/PUT/PATCH/
DELETE whose body has a data: URI in any string, on any route, core's or
a module's, with the usual 403 DEMO_LOCKED.

Each string is first read the way a browser reads an attribute (HTML
character references decoded, tabs and newlines removed), so &#100;ata: and
a data: split by a newline are refused too. "metadata:" and prose such as
"Data: none, yet" pass. Reads are never checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
whitlocktech added 1 commit 2026-10-27 20:21:58 -05:00
Merge branch 'main' into feature/demo-data-uri-lock
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 41s
PR Checks / client-build (pull_request) Successful in 55s
PR Checks / server-tests (pull_request) Successful in 6m25s
8ec7f399a7
whitlocktech approved these changes 2026-10-27 20:22:08 -05:00
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-10-27 20:22:14 -05:00
whitlocktech merged commit 0f05098d8f into main 2026-10-27 20:28:30 -05:00
whitlocktech deleted branch feature/demo-data-uri-lock 2026-10-27 20:28:31 -05:00
Sign in to join this conversation.
No description provided.