Skip to content

feat(uploads): upload-file-from-url on a pinned transport (supersedes #39) - #41

Merged
marselsel merged 4 commits into
mainfrom
feat/url-upload-integration
Aug 13, 2026
Merged

feat(uploads): upload-file-from-url on a pinned transport (supersedes #39)#41
marselsel merged 4 commits into
mainfrom
feat/url-upload-integration

Conversation

@marselsel

Copy link
Copy Markdown
Owner

What

Integrates @gutencoder's #39upload-file-from-url, the server-side URL fetcher held back from #34, now with its DNS-rebinding TOCTOU closed by connection-level IP pinning. His two commits are cherry-picked as authored; this PR supersedes #39.

A receipt already sitting in OneDrive/SharePoint reaches the books without its bytes crossing the model context. Off by default (LEXWARE_ENABLE_URL_UPLOAD), its own capability, requires the drafts tier (does not pull it up), and lives in its own file — the only tool that makes this server originate an outbound request to a destination the model chose, so it gets its own switch rather than riding in on drafts.

How the TOCTOU is closed

Every check-then-fetch SSRF filter resolves the host, validates the addresses, then hands the name to fetch, which resolves it again at connect time — two lookups, one checked. src/uploads/pinned-fetch.ts removes the second lookup: net.connect's lookup hook is fed the exact addresses the guard just vetted, so there is no second resolution to disagree with the first. Built on node:https (not an undici dispatcher) so no runtime dependency is added. TLS is untouched — SNI and cert validation still bind to the hostname; only the dialled address comes from the pin.

Three layers run at every redirect hop: host allow-list (dot-boundary) → resolved-address range check (loopback/private/link-local/metadata/CGNAT/… in every IPv4/IPv6/IPv4-in-IPv6 spelling, fail-closed) → pinning.

Review fixes applied on top of #39

  • Filename handling matches the ticket flow: the model filename override and the URL basename now run through sanitizeFilename, so ../../etc/passwd, an embedded CRLF, or an over-long name can't reach Lexware or the logs — and a trailing-slash URL no longer submits an empty filename. URL basename is percent-decoded; resolveDownloadName is total (never throws) and unit-tested.
  • URLs with embedded credentials are refused on the first URL and every redirect hop (node:https would otherwise put them on the wire as Authorization: Basic, where the fetch it replaced refused them).
  • Leading-dot allow-list entries (.sharepoint.com) are stripped instead of silently matching nothing; response body drained before the one redirect-error path that skipped it.

Verification

  • 321 tests / 19 files, including real-certificate TLS tests proving pinning doesn't weaken cert validation, a raw-ClientHello SNI check, and an RFC-6761 .invalid-host connection proof.
  • Reviewed through: full read, two adversarial passes on feat(uploads): upload-file-from-url with connection-level IP pinning (follow-up to #34/#38) #39's code, an adversarial pass on the fixes, /code-review high (3 LOW — 2 fixed, 1 declined as pre-existing/marginal), and /security-review high (no findings).

Ships as v0.1.12. Thanks @gutencoder — the node:https-over-dispatcher call (zero new deps) and the on-the-wire SNI test were the right instincts.

gutencoder and others added 4 commits August 13, 2026 17:07
Every check-then-fetch SSRF filter has the same hole, and 0.1.11's CHANGELOG
named it as the reason upload-file-from-url was held back: the guard resolves
the host and validates the addresses, then hands the *name* to fetch, which
resolves it again when it opens the socket. Two lookups, and only the first one
was checked. A DNS answer that differs between them — two records on a short
TTL, or deliberate rebinding — gets connected to without ever having been
looked at.

createPinnedFetch removes the second lookup rather than trying to make it agree
with the first: net.connect's `lookup` hook is fed the addresses the caller
already vetted, and consults DNS for nothing. No window is left, because there
is no second resolution.

Built on node:https rather than the undici dispatcher suggested in #34, for one
reason: undici is not a dependency here — `fetch` is Node's built-in copy, not
reachable as a module — so the dispatcher route means adding a network stack to
a server that fronts accounting data, and carrying two copies of undici in one
process. node:https already exposes the hook, so the guarantee is identical and
the dependency count does not move. Happy to switch if you would rather have
the dispatcher.

What pinning does NOT touch is certificate validation: SNI and
checkServerIdentity still come from the hostname, only the dialled address
comes from the pin. The test for that reads the SNI out of the raw ClientHello
on the wire rather than asking the client to report on itself. The connection
tests use a `.invalid` host (RFC 6761 guarantees it cannot resolve), so a
socket arriving at the listener can only have come from the pin — an unpinned
client fails with ENOTFOUND and never opens one.

Sockets are never pooled (fresh agent, keepAlive: false), so a connection
opened for a differently vetted request cannot be reused here.
Brings back the tool held out of #34, now that the TOCTOU its deferral named is
closed by the transport in the previous commit. It fetches a file from a share
link server-side and stores it in Lexware, so a receipt already sitting in
OneDrive/SharePoint reaches the books without its bytes crossing the model
context.

Off by default (LEXWARE_ENABLE_URL_UPLOAD), and in a file of its own rather
than beside the ticket flow. The ticket flow only ever receives bytes; this is
the only tool that makes the server originate an outbound request to a
destination the model chose. Different risk class, own switch — so enabling
drafts cannot hand an operator an outbound fetcher as a side effect. It needs
the drafts tier but deliberately does not pull it up the way finalize does, and
warns rather than ignoring the flag in silence.

LEXWARE_UPLOAD_ALLOWED_HOSTS configures the allow-list. Setting it replaces the
Microsoft defaults instead of extending them, so those domains can be opted out
of; an empty value blocks every host, which is how the fetcher is switched off
without unregistering it. `??` and not `||` for exactly that, with a test
pinning the behaviour and a startup warning so a typo is not mistaken for an
open door.

The guards from #34 are unchanged and still run first, at every hop: allow-list
matched on a dot boundary, then the resolved-address range check. Pinning is a
third layer, not a replacement for either — and the wiring between the check
and the connection has its own test, because that seam is exactly what a
refactor could quietly unhook with every other test still green.

npm run build, npm test (312 tests / 18 files, up from 251 / 15) and
docker build all pass.
….1.12

Integrates #39 (gutencoder's pinned-transport upload-file-from-url) with the
findings from the review applied on top. His two commits are cherry-picked as
authored; this commit is the fixes.

- Filename handling now matches the ticket flow: the model-supplied `filename`
  override and the URL basename run through sanitizeFilename (the same trust
  boundary), so `../../etc/passwd`, an embedded CRLF or an over-long name can't
  reach Lexware/logs, and a trailing-slash URL no longer submits an EMPTY
  filename (the `"" ?? default` bug, reintroduced from the #34 fetcher). The URL
  basename is percent-decoded first. resolveDownloadName is exported + unit-tested.
- URLs with embedded credentials are refused (first URL and every redirect hop):
  node:https would otherwise send them as Authorization: Basic on the wire, where
  the fetch it replaced refused such URLs outright.
- A leading dot on an allow-list entry (`.sharepoint.com`) is stripped instead of
  silently matching nothing (subdomain matching is already dot-boundary).
- Drain the response body before the "redirect without location" throw, the one
  post-fetch error path that skipped it.

The pinning itself was verified sound under adversarial review (real-cert TLS
tests prove validation still binds to the hostname, not the pinned address; no
SSRF escape, TOCTOU, decompression-cap, or multipart-injection path found), so
it is unchanged. Ships as 0.1.12. 320 tests.
…le only)

New high-severity advisory (nanoid <3.3.18, disclosed after 0.1.11's green
CI) tripped the audit gate. Transitive prod dep; npm audit fix bumps it to
3.3.18 in the lockfile only, package.json untouched. 321 tests green.
@marselsel
marselsel merged commit c2c9972 into main Aug 13, 2026
2 checks passed
@marselsel
marselsel deleted the feat/url-upload-integration branch August 13, 2026 19:16
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