Skip to content

Never let transferred state arrive with code already enabled - #5

Open
neoliminal wants to merge 1 commit into
ElodineOfficial:mainfrom
neoliminal:security/untrusted-state-code-exec
Open

Never let transferred state arrive with code already enabled#5
neoliminal wants to merge 1 commit into
ElodineOfficial:mainfrom
neoliminal:security/untrusted-state-code-exec

Conversation

@neoliminal

Copy link
Copy Markdown
Contributor

The problem

applyImportedCardCode (js/23-card-code.js:301) documents the rule and enforces it on the character-card file import: the decision to run code belongs to the person at the keyboard and can't be carried in a transferred blob. Two other paths reach the same executors and had no such gate.

restoreFromServer() (js/06-state-sync.js:376-381) parses the /state blob and writes it straight into IndexedDB or localStorage, then reloads. Boot then calls applyExtensions() (js/24-boot.js:43) and applyCardCode() (:46) against it. state.extensions.scripts[].raw gets appended to <head> as a script element (js/19-extensions.js:70); card.customCode goes through new Function (js/23-card-code.js:204), gated only by customCodeEnabled, which survives the restore intact.

/state is password-gated, so this needs an authenticated peer — but it's a single shared document every paired device can overwrite. A compromised phone, or anyone who learned the shared password, can hand every other device code that runs at next boot. The restore that fires when local storage is empty (js/06-state-sync.js:265) doesn't prompt at all, which is exactly the state of a phone hitting a new DHCP address or a freshly cleared browser.

importAllData('all') (js/21-data.js:122-157) has the same shape: it assigns characterCards and extensions from an arbitrary backup file and calls applyExtensions() in the same tick, behind a confirm() that only mentions replacing data.

The fix

neutralizeUntrustedCode(), added next to applyImportedCardCode and called on both paths. Code text is preserved so it can still be read and opted into; only the enable flags are cleared, and the user is told rather than it happening silently.

On the cost

Restores are rare — a new device, a cleared cache, a quota recovery — rather than part of the routine push, which is a PUT from this device and unaffected. So the practical cost is re-ticking a box once per device, which is what the file-import path already asks for.

I considered preserving the flag when the incoming code is byte-identical to what's already enabled locally, which would remove even that cost. It's more moving parts for a case that only comes up on a device that already has the card, so I went with the simpler rule — happy to switch if you'd prefer.

Testing

Unit-tested: enabled cards are disabled and their code kept, the extensions list is disabled and its scripts kept, null and {} are handled, and the call lands before saveState() / applyExtensions() on the import path.

Found during a security review.

applyImportedCardCode documents the rule and enforces it on the character
card file import: the decision to run code belongs to the person at the
keyboard and cannot be carried in a transferred blob. Two other paths
reach the same executors and had no such gate.

restoreFromServer() parses the /state blob and writes it straight into
IndexedDB or localStorage, then reloads; boot then calls applyExtensions()
and applyCardCode() against it. /state is password-gated, but it is a
single shared document every paired device can overwrite, so a
compromised phone -- or anyone who learned the shared password -- could
hand every other device a card with customCodeEnabled already true, or an
extensions list whose raw scripts get appended to <head>. The restore that
fires when local storage is empty (a new LAN IP, a cleared cache) does not
even prompt first.

importAllData('all') has the same shape: it assigns characterCards and
extensions from an arbitrary backup file and calls applyExtensions() in
the same tick, behind a confirm() that only mentions replacing data.

Add neutralizeUntrustedCode() next to applyImportedCardCode and call it on
both paths. Code text is preserved so it can still be read and opted into;
only the enable flags are cleared, and the user is told rather than it
happening silently. Restores are rare -- a new device, a cleared cache, a
quota recovery -- rather than part of the routine push, so the cost is
re-ticking a box once per device, which is what the file-import path
already asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ElodineOfficial added a commit that referenced this pull request Aug 21, 2026
@neoliminal - pin SHA-256 of engine + embed model (#8), strip run-flags from transferred state (#5), sanitize GGUF metadata reaching generated .cmd (#3), escapeHtml fallback (#4), firewall / URL ACL scoping (#7).
@wizzense - fall back to loopback when the LAN bind is denied (#10).
@jmccardle - Linux wine catch, roadmaps for Linux (#2).
@DawidKorach - stable port assignments, llama health registration in the CMD (#14, #15).

Co-authored-by: neoliminal <john.kipling.lewis@gmail.com>
Co-authored-by: wizzense <37890504+wizzense@users.noreply.github.com>
Co-authored-by: John McCardle <mccardle.john@gmail.com>
Co-authored-by: Dawid Korach <dawidk6@gmail.com>
@jmccardle

Copy link
Copy Markdown
Contributor

I recommend closing this PR - jmccardle#5 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants