feat(upload)!: opt-in form fields via lvt-upload-with - #150
Conversation
A Proxied upload auto-fires on file selection, so the enclosing form was POSTed to the upload endpoint with no submit-time moment for the user to notice. The denylist that guarded this excluded only type="password" — CSRF tokens, hidden secrets and autocomplete="current-password" text inputs all still rode along. Invert the default: a field travels only when marked lvt-upload-with. Forgetting to mark a field the handler needs now surfaces as a missing value in OnUpload — a visible bug — instead of a silent leak. Names are collected from the marked elements and FormData still does the serializing, so successful-control semantics (unchecked boxes, disabled controls, multi-selects) stay the browser's job. Also documents two contract questions raised in review: the file part wins a name collision with a marked field, and when one selection carries several files the marked fields ride along with every request so each reaches OnUpload self-describing. BREAKING CHANGE: form fields no longer travel with a Proxied upload unless marked lvt-upload-with. Add the attribute to each field an OnUpload handler reads (typically a record id). Refs livetemplate/livetemplate#452 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ui2cwpeGkrUfRt8rh2FgGG
ReviewWhat this does: Flips form-field carrying on a Proxied auto-upload from a denylist (exclude Correctness
Security
Tests
Nits (non-blocking)
No blocking issues found — this looks correct, well-tested, and appropriately scoped as a breaking change given the alpha status. |
The "fields ride along with every request" contract was documented in the handler comment, the uploads reference and livetemplate#452, but every test drove a single file — so a regression to first-only or last-only would have gone unnoticed. Assert both POSTs carry the marked field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ui2cwpeGkrUfRt8rh2FgGG
|
Thanks — both nits actioned: Chunked-path asymmetry → filed as livetemplate/livetemplate#508 so it doesn't get lost. Agreed it's more visible now than before: #452 turned field-carrying into an explicit author-declared contract, and an explicit contract that quietly doesn't hold on one transport is more surprising than the incidental behaviour it replaced. Multi-file / multi-trigger implication in the docs → covered in livetemplate/docs#120, which documents both the form-scoped marking ("a marked field travels with every upload fired from its form") and the per-request multi-file behaviour. I also added a test this round ( |
ReviewWhat it does: Flips Proxied-upload form-field serialization from a denylist (exclude Correctness
Test coverageGood — and notably, the PR description states the new/changed tests were run against the old implementation first and 3 of 4 failed there for the right reason (the leak reproduced), which is convincing evidence the tests actually discriminate old vs. new behavior rather than just passing trivially. The radio-group and multi-file-request tests cover the trickier edge cases well. Minor observations (non-blocking)
Overall: a well-scoped, well-tested fix for a real fail-open vulnerability, with a sensible fail-closed replacement. No bugs found. |
|
Round 2 raised no new functional issue, so stopping here rather than pushing another round. Answering the two new nits by reply: No Doc comments verbose for a private method — deliberate, and I'd keep them. What's recorded there isn't how the method works (that's readable from six lines of code) but why the default is off: the next person to look at this will be someone who marked a field, forgot, and is debugging a missing value in Chunked-path asymmetry — already filed as livetemplate/livetemplate#508 after round 1. |
Closes the client half of livetemplate/livetemplate#452.
The problem
A Proxied upload auto-fires on file selection — there is no submit for the user to review — and the client POSTed the entire enclosing form to the upload endpoint. The only guard was a denylist excluding
type="password", so a CSRF token, a hidden secret, or anautocomplete="current-password"text input all still travelled.A denylist fails open: anything the author didn't anticipate leaks silently.
The change
Inverted to opt-in. A field travels only when marked
lvt-upload-with:idreachesOnUpload;csrfdoes not. Failing to mark a needed field now surfaces as a missing value in the handler — a visible bug — rather than as a leak.Names are collected from the marked elements and
FormDatastill does the serializing, so successful-control semantics (unchecked boxes,disabledcontrols, multi-<select>) remain the browser's job rather than something reimplemented here.Two contract questions raised in the client#130 review are answered in code comments:
set, notappend).OnUploadself-describing.Verification
Four tests cover the contract. Crucially, they were run against the old denylist implementation to confirm they discriminate — 3 of the 4 fail there, for the right reason:
A test that passes both before and after would prove nothing; these fail before and pass after.
Full suite: 819 passed, 39 suites,
tsc --noEmitclean.Browser-verified end-to-end against this exact bundle via the
upload-modeschromedp e2e in livetemplate/docs (all 4 tests, WS-up and WS-disabled paths).Compatibility
Breaking, and deliberately not phased — the project is alpha with no external users, so this collapses straight to the end state rather than shipping a deprecation window.
Note the docs PR is safe to merge in either order: the marker is an inert unknown attribute to the currently-published client, which still delivers the field via the old denylist.
Out of scope
Field-carrying is multipart-only; the WS chunked path (
upload_chunk/upload_complete) never carried form fields and still doesn't. That transport asymmetry is pre-existing and unchanged here — worth a separate look if marked fields should reach chunked uploads too.🤖 Generated with Claude Code
https://claude.ai/code/session_01Ui2cwpeGkrUfRt8rh2FgGG