Skip to content

📦 3.1.0 - #920

Merged
dr-dimitru merged 40 commits into
masterfrom
dev
Oct 1, 2026
Merged

dr-dimitru merged 40 commits into
masterfrom
dev

Conversation

@dr-dimitru

Copy link
Copy Markdown
Member

What's new

This release closes several upload and download security holes, fixes the client upload state machine, and supports Meteor 3.2 to 3.5. Read "Major changes" before you upgrade. See the new security guide.

Major changes

  • ⚠️ Require Meteor 3.2 or newer. ostrio:cookies 3.0.0 needs fetch@0.1.6, which first ships with Meteor 3.2.
  • ⚠️ Restart uploads that were in progress during the upgrade from 3.0.x. The server answers 403 or 410 for upload records created by 3.0.x.
  • ⚠️ Return HTTP upload errors as { error: <status code>, reason, isClientSafe? } instead of { error: "<text>" }. 5xx responses have a generic reason.
  • ⚠️ Limit upload sizes. chunkSize is at most 16 MiB. HTTP Start bodies, including meta, are at most 1 MiB. HTTP EOF bodies are at most 64 KiB. HTTP chunk bodies are at most the Base64 size of chunkSize plus 4 KiB.
  • ⚠️ Reject FileUpload pipes that grow a chunk. A pipe must return Base64 that decodes to the same number of bytes as its input, so per-chunk encryption with an IV, a tag, or padding fails on the first chunk. Move such transforms to onAfterUpload on the server.
  • ⚠️ Emit only error and end when an upload fails. pause, abort, and onAbort now fire only when abort() is called.
  • ⚠️ Throw Meteor.Error 404 from the synchronous FilesCursor methods (get, fetch, first, last, next, previous, hasNext, count, forEach, each, map, current, remove) and from FileCursor#with() and FileCursor#remove() on the server. They returned Promises or wrong values before. Use the *Async methods.
  • ⚠️ Reject writeAsync() and loadAsync() with 409 when opts.fileId already exists. Before, they overwrote the existing file.
  • ⚠️ Reject an upload Start with file.size 0 with 400. An empty upload has no chunk to send and never finished. The client already refused empty files.
  • ⚠️ Stop retrying a chunk after 10 consecutive network failures and end the upload with an error. Before, the client retried without limit.

Other Changes

Fixed

  • 🔧 Accept Write, EOF, and abort requests only from the user who started the upload.
  • 🔧 Stop _Abort from removing finished files. It removes only the unfinished upload record and its partial file, and answers 404 for unknown or foreign upload ids.
  • 🔧 Remove the partial file when an upload is aborted after a server restart. The server removes it only when device, inode, and birth time match the file the upload created. Without a stored birth time (file systems that do not report it), device and inode must match.
  • 🔧 Prevent an upload from overwriting or deleting another file. Start returns 409 if the file id, the target path, or a pending upload path already exists, and creates the file exclusively.
  • 🔧 Fail an upload with 410 or 409 when its file was removed or replaced on disk. The server compares device, inode, and birth time on every reopen.
  • 🔧 Sanitize namingFunction output per path segment and require the final path to stay inside storagePath.
  • 🔧 Ignore client-supplied reserved file fields: _id, fileId, path, _storagePath, _downloadRoute, _collectionName, versions, userId, public, extension, mime, and type flags.
  • 🔧 Store the real size on disk, and reject chunks beyond the declared size.
  • 🔧 Validate chunk size, chunk count, chunkId, and chunk length.
  • 🔧 Reject oversized HTTP request bodies with 413 before buffering them.
  • 🔧 Run the same check() on HTTP Start as on DDP Start.
  • 🔧 Stop sending server paths to the client in Start and EOF responses.
  • 🔧 Destroy download streams when the client disconnects. Stream errors return a generic 500.
  • 🔧 Match routes by URL pathname, so a query string or a route prefix can no longer match another route.
  • 🔧 Encode Content-Disposition with an ASCII filename and an RFC 8187 filename*. The ASCII fallback replaces % with _.
  • 🔧 URI-encode _id, version, extension, and collection name in link() and the fileURL helper, and never produce protocol-relative URLs.
  • 🔧 Return an empty string from link() and the fileURL helper for a file without _id, for example the file passed to end after a rejected upload. It returned /undefined/original/undefined.txt before.
  • 🔧 Set the x_mtok cookie as secure on https pages.
  • 🔧 Ignore inherited keys in the session lookup and fail closed.
  • 🔧 Skip __proto__, constructor, and prototype in fixJSONParse and fixJSONStringify.
  • 🔧 Limit a numeric protected result to 400-599.
  • 🔧 Return the stored file on a repeated EOF only to the authenticated owner, so a lost EOF response no longer fails a finished upload.
  • 🔧 Answer an EOF that arrives while the first EOF of the same upload is still running (for example after a DDP reconnect) with the result of the first one instead of 408.
  • 🔧 Accept an object in responseHeaders.
  • 🔧 Serve suffix ranges (bytes=-N) and the last byte, clamp the range end to the file size, and return 200 for multi-range requests. Unsatisfiable ranges return 416 when strict is true and 200 when it is false.
  • 🔧 Send Content-Length on 206 responses that serve() reads from disk, instead of Transfer-Encoding: chunked. A response never carries both headers.
  • 🔧 Send 206 responses from a custom readableStream chunked, without Content-Length, because serve() can not know how many bytes the stream sends. A Content-Length set in responseHeaders is kept. A 200 still gets Content-Length from the stored size, as in 3.0.
  • 🔧 Give up a Start or EOF request, or a chunk answered with 502, 503, or 504, after 5 failed attempts, as documented. Before, it took 6.
  • 🔧 Make the integrity-check 400 response reachable again.
  • 🔧 Stop loadAsync() from throwing inside a timer. It opens the file only after a successful fetch, takes size from disk, and removes only partial files it created.
  • 🔧 Make writeAsync() create the directory and always close the file handle.
  • 🔧 Complete an upload when all chunk ids arrived instead of guessing from the file size. Uploads resume after a server restart.
  • 🔧 Return 503 when a chunk write fails.
  • 🔧 Make WriteStream#end() return false for aborted or incomplete streams, and stop abort() from deleting a finished file.
  • 🔧 Remove the uploaded file when the database insert fails, and rethrow insert errors.
  • 🔧 Run namingFunction and storagePath once per upload, at Start.
  • 🔧 Run onInitiateUpload after the server checked that the target does not exist.
  • 🔧 Find public route ids that contain -.
  • 🔧 Set extensionWithDot to an empty string for files without extension.
  • 🔧 Treat a responseHeaders function that returns nothing as {}.
  • 🔧 Catch version unlink failures and log index creation errors.
  • 🔧 Treat a file that is already gone as removed in unlinkAsync() and removeAsync(). The server logs one debug line without a stack trace.
  • 🔧 Send one client request at a time: Start, chunks in order, then EOF. Network failures, 502, 503, and 504 retry with backoff from 500 ms up to 10 s.
  • 🔧 Prevent the client from sending a chunk before Start succeeds, and fire end and uploaded only after a successful EOF.
  • 🔧 Send Start when continue() follows a pause before Start.
  • 🔧 Stop abort() from causing an unhandled rejection when the server answers 404.
  • 🔧 Pass the reason of HTTP 4xx responses to error, onError, and end.
  • 🔧 Call error and end once per failed upload. A throwing callback is reported with console.error and does not stop the others.
  • 🔧 Keep a manual pause when the connection drops and comes back.
  • 🔧 Omit the x-mtok header when there is no session instead of sending the string "null".
  • 🔧 Fix Base64 padding for chunk sizes not divisible by 4.
  • 🔧 Stop insert() and insertAsync() from changing the config object passed by the user.
  • 🔧 Keep meta Dates as Dates after an HTTP upload.
  • 🔧 Start the worker, the beforeunload listener, timers, and trackers only after onBeforeUpload passes, and always clean them up.
  • 🔧 Post worker errors to the main thread and share one Blob URL between collections.
  • 🔧 Stop the client constructor from crashing on new FilesCollection().
  • 🔧 Update the x_mtok cookie after reconnect and login without overwriting the app's onReconnect hook.
  • 🔧 Reject client removeAsync() with 401 when allowClientCode is false.
  • 🔧 Accept null, undefined, and FileCursor in link().
  • 🔧 Accept the same selectors in countDocuments() as in find(), including ObjectID.
  • 🔧 Honor limit and skip in hasNextAsync() and lastAsync().
  • 🔧 Stop estimatedDocumentCount() from failing in debug mode.
  • 🔧 Fix typos in JSDoc and error messages.
  • 🔧 Match index.d.ts to the runtime API. Many options and method signatures were missing or wrong.
  • 🔧 Keep uploads, downloads, and writes running when a custom debug function throws. The error goes to the console.
  • 🔧 Throw from _dataToSchema() when storagePath resolves to a Promise that was not awaited, so a Promise never lands in a stored document.

Added

  • ✨ Add the allowedCordovaOrigins option on Server and Client, passed to ostrio:cookies. Cordova and Meteor-Desktop need it for query-string cookies. The Server default is the value of allowedOrigins.
  • ✨ Add the uploadIdleTimeout Server option (milliseconds, default 900000). It closes file handles of idle uploads, and the next chunk reopens them.
  • ✨ Add the nosniff Server option. It sets X-Content-Type-Options: nosniff (default false, planned to default to true in v4).
  • ✨ Warn on server start when allowClientCode is on and onBeforeRemove is not set.
  • ✨ Add userAsync() to the client _getUser() result.
  • ✨ Allow the debug option to be a function. The server and client pass log arguments to it instead of the console. Thanks to @jankapunkt, Feature/debug function #907.
  • ✨ Allow the storagePath function to be async. collection.storagePath(fileObj) returns a Promise in this case, so await it. Thanks to @codeonprod, async support added for storagePath() function #909.

Changed

  • 👨‍💻 Use the stored _downloadRoute and _collectionName in link() when they are safe, otherwise the collection's own values. A stored route with ?, #, or an encoded ., /, or \ (%2e, %2f, %5c) is not safe.
  • 👨‍💻 Answer ?play=true requests without a Range header with 200 and the whole file, as RFC 9110 requires. Requests with Range still get 206.
  • 👨‍💻 Add ; charset=utf-8 to the default Content-Type of text/*, application/json, application/javascript, and image/svg+xml files without a charset, so text files display correctly inline. Text files in another encoding, such as a Latin-1 CSV, need their charset set through responseHeaders.
  • 👨‍💻 Ask the user to check the connection when one chunk fails 10 times in a row, and name a chunk larger than chunkSize (for example from a pipe) as one possible cause.
  • 👨‍💻 Export types from index.d.ts at the top level. The old declare module wrapper exported nothing.
  • 👨‍💻 Keep FileUpload pipes in reverse order of registration. This changes in v4.
  • 🤫 Ignore docs/audits/ in git.

Docs

  • 📔 Add a security guide.
  • 📔 Rename the migration doc to migration-to-v3.md.
  • 📔 Rewrite the AWS S3 (@aws-sdk/client-s3 v3), Dropbox, Google Cloud Storage, GridFS, and sharp thumbnail guides with async APIs and correct Range handling.
  • 📔 Document updateAsync, countDocuments, estimatedDocumentCount, serve, download, allow/deny, the exported helpers, and the progress event arguments.
  • 📔 Document upload rules, limits, and the HTTP error body in about-transports.md.
  • 📔 Document the event order: abort() emits pause, then abort, and no end. A failed upload emits error, then end. Document that abort() does not cancel an EOF or HTTP Start request that is already sent.
  • 📔 Document that a custom readableStream passed to serve() for a Range request should contain exactly the requested bytes, and when serve() sets Content-Length.
  • 📔 Fix wrong defaults, broken links, typos, and samples that did not run on Meteor 3.
  • 📔 Add CLAUDE.md with commands, architecture notes, and invariants for contributors and coding agents.
  • 📔 Update the README, the docs index, and CONTRIBUTING.md: Meteor 3.2 requirement, links to the security and migration guides, and Isomorphic instead of Anywhere in API labels.
  • 📔 Document the debug function and the async storagePath option in docs/constructor.md, and await storagePath() in the sharp thumbnail guide.

Tests

  • 🧪 Add tests/security.test.js for upload ownership, path safety, request limits, Range parsing, and access checks.
  • 🧪 Port Tinytest helpers to mocha and fix tests that passed without asserting.
  • 🧪 Cover Content-Length on 200 and 206 for files and custom streams, HEAD, ?play=true, default charsets, racing EOFs (also anonymous), abort after restart (also for records without file identity), link() without _id, encoded routes, and unlink of missing files.
  • 🧪 Check types with tsc and tsd (npm run typecheck).
  • 🧪 Cover the debug function option and async storagePath (Promise return, rejection, startup, writeAsync, loadAsync).
  • 🏗️ Run lint, typecheck, and tests in CI on Meteor 3.2.2 and 3.5.2.
  • 🏗️ Replace .eslintrc with an ESLint 9 flat config, without the deprecated no-extra-semi and no-native-reassign rules.

Dependencies

  • 📦 ostrio:cookies 3.0.0
  • 📦 eventemitter3 5.0.4
  • 📦 meteortesting:mocha 3.4.0, chai 6, sinon 22 for tests
  • 📦 Update the dev dependency lockfile (js-yaml, ESLint 9, TypeScript 5.9, tsd) and the package npm-shrinkwrap.json

dependabot Bot and others added 30 commits November 21, 2025 21:43
Bumps  and [js-yaml](https://github.com/nodeca/js-yaml). These dependencies needed to be updated together.

Updates `js-yaml` from 4.1.0 to 4.1.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](nodeca/js-yaml@4.1.0...4.1.1)

Updates `js-yaml` from 3.14.1 to 3.14.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](nodeca/js-yaml@4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
- dependency-name: js-yaml
  dependency-version: 3.14.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
tslint is archived and ESLint 8 is end of life. The repo-level flat config also stops ESLint from picking up configs from parent directories.
Meteor 3.2 is the oldest release the dependencies resolve on, and 3.5.2 is the latest.
ostrio:cookies 3.0.0 needs fetch@0.1.6, which first ships with Meteor 3.2. Tests now run on mocha only, so the Tinytest helpers moved to tests/helpers.test.js.

BREAKING CHANGE: Meteor 3.0 and 3.1 are no longer supported. Upgrade the app to Meteor 3.2 or newer.
…mits

Any client could delete any file record through _Abort, take over another user's upload by its id, overwrite or delete other files through a chosen file name, and send chunks of any size. Uploads now belong to the user who started them, files are created exclusively and checked by device and inode, paths must stay inside storagePath, and sizes, chunk ids, and request bodies are bounded.

Range parsing, Content-Disposition encoding, route matching, link() URL encoding, and several write-stream bugs are fixed in the same change because they share the upload and serve code paths.

BREAKING CHANGE: HTTP upload errors use { error: <status>, reason, isClientSafe? }. chunkSize is at most 16 MiB, Start bodies at most 1 MiB, EOF bodies at most 64 KiB. Uploads started on 3.0.x must restart. writeAsync() and loadAsync() reject with 409 for an existing fileId. Start rejects size 0 with 400.
The upload could send duplicate chunks after a reconnect, stall when Start or EOF failed, report failures as aborts, and leak workers and timers. It now sends one request at a time, retries network errors with backoff, surfaces server reasons, and cleans up on every exit path. Server-side sync cursor methods returned Promises or wrong values, so they now throw.

BREAKING CHANGE: a failed upload emits only error and end, not pause, abort, or onAbort. A chunk is retried at most 10 times in a row on network failures. FileUpload pipes must not grow a chunk. Sync FilesCursor and FileCursor methods throw on the server; use the *Async methods.
The declarations sat inside a declare module wrapper that exported nothing, and many options and signatures did not match the code. index.test-d.ts covers them with tsd.
Several samples threw or used APIs removed in Meteor 3 and MongoDB driver 4, and constructor.md listed wrong defaults. The security guide explains the defaults that expose files, such as protected: true and allowClientCode.
Linux reuses an inode number right after unlink, so a file recreated at the upload path passed the device and inode check. The identity now includes the birth time in nanoseconds when the file system reports it.
…ests

Replace `no-native-reassign` with `no-global-assign` and drop
`no-extra-semi`, so the config keeps working after these rules are
removed. Each helper test had its own `describe` with one `it`.
- Send Content-Length on 206 responses and never with
  Transfer-Encoding: chunked. A response with both is invalid, and some
  clients and proxies reject it.
- Answer ?play=true without a Range header with 200, as RFC 9110 requires.
- Add charset=utf-8 to the default Content-Type of text types, so text
  files display correctly inline.
- Replace % with _ in the ASCII filename fallback (RFC 6266).
- Treat ENOENT as success in unlinkAsync() and removeAsync().
- On _Abort without a stream in memory, remove the partial file when its
  identity matches. Before, the file stayed on disk.
- An EOF that races the first EOF (DDP reconnect) waits for it and gets
  the same result instead of 408.
The `end` callback of a rejected upload gets a file object without
`_id`, and link() turned it into `/undefined/original/undefined.txt`.
A stored route with `?` or `#` would change the query or fragment of
the URL, so it is no longer treated as safe.
Exceptions thrown by user callbacks went to Meteor._debug, which logs
at the same level as other debug output, so they were easy to miss.
The retry limit error blamed pipes even on a plain network outage, and
it fired after 11 failed attempts while the docs said 10.
The demo upload form stayed on "Uploading" after abort() because the
docs did not say that abort() emits no `end`.
serve() can not know how many bytes a caller's readableStream sends. A
stream that is not cut to the requested range, or a compressed stream,
then sends more or fewer bytes than Content-Length says, and the extra
bytes corrupt the next response on a keep-alive connection. Responses
from a custom stream are sent chunked again, as in 3.0 for 206. A 200
from a custom stream no longer gets Content-Length from the stored
size. Responses that serve() reads from disk keep Content-Length.
…loads

An anonymous owner's racing EOF takes the null === null owner path, and
an abort after restart must keep the file of a record without a stored
file identity.
…ckslashes

URL parsers decode `%2e%2e` to `..`, so an encoded route could still
contain a dot segment.
The docs say 5 attempts, but the check allowed a 6th.
Users who pass their own stream to serve() need to know that it should
hold exactly the requested range, and that Content-Length from serve()
does not fit a gzip-encoded body.
3.0 sent Content-Length with the stored size on a 200 from a caller's
stream, and download progress in browsers relies on it. Only 206 from a
custom stream stays without Content-Length, because a stream that is not
cut to the range would send more bytes than the header says.
dr-dimitru and others added 10 commits October 1, 2026 19:14
Await storagePath in upload start, writeAsync, and loadAsync. Sync
functions and strings keep returning a string. An async function
returns a Promise, so callers must await it. At startup the directory is
created best effort when the async function resolves for an empty object.

Based on #909.

Co-authored-by: Surya Rath <18166257+codeonprod@users.noreply.github.com>
v3.1.0: secure uploads, fix the client upload flow, support Meteor 3.2 to 3.5
@dr-dimitru
dr-dimitru merged commit 8ecfe54 into master Oct 1, 2026
8 checks passed
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