Skip to content

feat(uploads): ticket-gated uploads without base64 through the model (supersedes #34) - #38

Merged
marselsel merged 4 commits into
mainfrom
feat/uploads-ticket-flow
Aug 13, 2026
Merged

feat(uploads): ticket-gated uploads without base64 through the model (supersedes #34)#38
marselsel merged 4 commits into
mainfrom
feat/uploads-ticket-flow

Conversation

@marselsel

Copy link
Copy Markdown
Owner

What

Integrates the base64-free upload path contributed by @gutencoder in #34, scoped to the ticket flow:

  • create-upload-ticket / get-upload-result (drafts tier) plus the single-use GET/POST /upload/:ticket route and its dependency-free drag-and-drop page. Bytes go client → server → Lexware; the model only ever sees the resulting file id.
  • @gutencoder's two commits are included as authored (cherry-picked onto current main, not squashed), so the history credits him directly. This PR supersedes feat(uploads): ticket-gated and URL uploads without base64 in the model context #34.

What was deliberately left out

upload-file-from-url (the server-side URL fetcher) is deferred, not rejected. A server-side fetcher is SSRF surface by construction, and the #34 implementation — though carefully guarded (host allow-list first, per-hop re-validation, fail-closed address parsing) — carries a DNS-rebinding TOCTOU between the address check and the connection's own re-resolution. That is moot for the built-in Microsoft allow-list, but becomes live the moment an operator configures a custom LEXWARE_UPLOAD_ALLOWED_HOSTS. It will be reconsidered separately with connection-level IP pinning, disabled by default. The CHANGELOG Security note records this.

Hardening added on top

  • /upload routes mount only when the drafts capability is enabled — a read-only deployment exposes no write route.
  • Ticket page served with Cache-Control: no-store, X-Content-Type-Options: nosniff, X-Frame-Options: DENY.
  • One buffering body per ticket at a time (429 for a concurrent second POST; slot released on response close).
  • The upload result stays readable for a full TTL after completion (a minute-14 upload previously left get-upload-result a sub-minute window and invited a duplicate re-upload); in-flight entries are never expiry-evicted.
  • The generated curl command always pins Content-Type--data-binary otherwise silently declares application/x-www-form-urlencoded and defeats the documented fallback chain.
  • A Lexware 401/403 (the operator's API key being rejected) is answered as a generic 502 instead of forwarding upstream status and wording to the unauthenticated uploader; unknown-outcome transport failures name the duplicate risk.
  • get-upload-result annotated read-only (it is designed to be polled); __PORT-aware loopback links under skybridge dev; startup warning for a stale OAUTH_RESOURCE outside OAuth mode.

Verification

  • 250 tests across 15 files, including an end-to-end test that executes the emitted curl command against the live routes and a client-abort slot-release test.
  • Three independent adversarial review rounds plus a dedicated security review of the final branch: no critical or high findings open.

Ships as v0.1.11. Thanks @gutencoder — the body-parsing deferral, the X-Filename-B64 design, and the measured, reproduce-first commit messages made this one a pleasure to review.

Contributor and others added 4 commits August 12, 2026 19:38
…el context

upload-file / upload-voucher-file carry the file as base64 inline in the
JSON-RPC body: every byte is billed as tokens, lands in the transcript, and a
~8 MB receipt runs into the 12 MB body limit — even though the model has no
use for the contents.

Adds three drafts-tier tools that route the bytes around the model:
create-upload-ticket (short-lived single-use ticket -> browser page or a curl
one-liner), get-upload-result, and upload-file-from-url (server-side fetch).

Supporting pieces: an in-memory ticket store, GET/POST /upload/:ticket with a
self-contained page, and an SSRF-hardened URL fetcher (host allowlist,
redirect re-validation, private/loopback/link-local rejection after DNS
resolution). Filenames travel as X-Filename-B64 because a raw header value is
Latin-1 on the wire and fetch() rejects anything above U+00FF.

LEXWARE_UPLOAD_ALLOWED_HOSTS configures which hosts upload-file-from-url may
download from. It replaces the built-in defaults rather than extending them so
they can be opted out of, and an empty value blocks every host rather than
allowing all. The default is the previous hard-coded list, so behaviour is
unchanged when it is unset.

/upload paths are deferred from the global JSON parser alongside /mcp: the
routes read the raw body themselves, and letting the JSON parser run first
turned a JSON-content-typed upload into an empty file.

The base64 tools are unchanged; this is additive.
…/filename paths

Four review findings on the ticket-gated upload path.

The public base URL the upload links are built from was derived from the auth
mode — the OAuth resource, otherwise http://127.0.0.1:$PORT. SERVER_URL was
read only inside the OAuth branch, so a static-token deployment behind a real
domain (a documented, supported mode) set it, had it ignored, and handed the
model a browser URL and a curl command pointing at the container's own
loopback interface. It is now resolved once in loadConfig from OAUTH_RESOURCE
or SERVER_URL, independently of the auth mode, and lives on Config rather than
AuthConfig — where the server is reachable is a deployment fact, not an auth
one. It goes through the same normalizeUrl validation as every other
configured URL, so a typo fails at startup instead of being pasted into a
command an operator runs. Unset, the loopback fallback still applies on the
configured port; OAuth deployments are unaffected (the value is the same
string the token audience is built from).

The upload page built its success message with innerHTML, interpolating a file
id that comes back from the Lexware API. It is now a text node inside a <code>
element: same rendering, no parsing. The error path already did this.

sanitizeFilename only stripped path separators and trimmed the ends, so
anything in the middle survived: filename*=UTF-8''evil%0D%0Ainjected.pdf
arrived as "evil\r\ninjected.pdf" and went on into the multipart field and
into anything that logs the name. C0 controls and DEL are now removed wherever
they appear, and the result is capped at 255 characters (the per-component
limit of ext4/XFS/APFS/NTFS) without splitting a surrogate pair. A name left
empty by this returns undefined, which the existing fallback chain already
handles. Legitimate non-ASCII names are untouched.

Expired tickets were only removed by the sweep in create(), so an instance
that issued tickets and then went quiet held them for as long as it stayed up.
claim() and peek() now evict what they find expired. Externally unchanged:
still 410, still undefined.

Tests: 241 -> 255. Each fix was mutation-probed — reverted, the covering test
fails, restored, the suite is green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Integrate the base64-free upload path from #34, scoped to the ticket flow
(create-upload-ticket / get-upload-result + the /upload/:ticket route) and
hold back upload-file-from-url.

- Remove upload-file-from-url, the SSRF URL fetcher (fetch-url.ts), the host
  allow-list config (LEXWARE_UPLOAD_ALLOWED_HOSTS) and their tests. Keep the
  shared sanitizeFilename helper as src/uploads/filename.ts.
- Mount the /upload routes only when the drafts capability is enabled, so a
  read-only deployment never exposes the unauthenticated write route.
- Serve the ticket page Cache-Control: no-store + X-Content-Type-Options: nosniff.
- Bump to 0.1.11; CHANGELOG records the deferral and its reason.

The URL fetcher carries a DNS-rebinding TOCTOU that is moot for the built-in
Microsoft defaults but live for any custom allow-list; it will be reconsidered
separately with connection-level IP pinning, disabled by default.
Correctness:
- buildCurlCommand always pins Content-Type: a valid token-shaped mimeType is
  carried; otherwise `-H 'Content-Type:'` strips curl's --data-binary default
  (application/x-www-form-urlencoded), which silently won over the server's
  documented ticket-mimeType/octet-stream fallback for every headerless upload.
  Locked by an end-to-end test that executes the emitted command with real curl.
- TicketStore.complete() re-arms the TTL, so get-upload-result can read the
  file id for a full 15 minutes after the upload FINISHED (a minute-14 upload
  previously left a sub-minute window and invited a duplicate re-upload), and
  in-flight entries are never expiry-evicted by racing claim()/peek()/sweep().

Hardening:
- One buffering body per ticket at a time (429 for a concurrent second POST,
  slot released on response close): claim() only runs after express.raw() has
  buffered the body, so N parallel POSTs on one leaked ticket could each buffer
  up to 20 MB before N-1 lost the race.
- A Lexware 401/403 (the OPERATOR's API key rejected) maps to a generic 502
  instead of forwarding upstream status and wording to the unauthenticated
  ticket holder; a status-0 transport failure now says the outcome is unknown
  and warns against blind retries (duplicate risk).

Polish:
- get-upload-result annotated LOCAL_RO (it only peeks the store and is meant
  to be polled; WRITE made clients confirm every poll).
- Loopback link fallback follows __PORT (skybridge dev binds it, not PORT).
- Startup warning when OAUTH_RESOURCE is set outside OAuth mode (it silently
  steers upload links; the stale-after-migration footgun).
- Degraded-mode warning no longer overclaims: only JSON-content-typed ticket
  uploads are size-capped by the global parser, other types work.
- Upload body forwarded without a redundant full-size Uint8Array copy.
- CHANGELOG stops claiming the single-use lock prevents ALL duplicates: after
  an unknown-outcome transport failure a blind retry can still duplicate.
@marselsel
marselsel merged commit ea71095 into main Aug 13, 2026
2 checks passed
@marselsel
marselsel deleted the feat/uploads-ticket-flow branch August 13, 2026 10:21
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.

1 participant