Skip to content

fix(api): refuse file uploads unless a plugin declared the path - #7851

Open
ar2rsawseen wants to merge 7 commits into
masterfrom
fix/upload-temp-file-cleanup
Open

fix(api): refuse file uploads unless a plugin declared the path#7851
ar2rsawseen wants to merge 7 commits into
masterfrom
fix/upload-temp-file-cleanup

Conversation

@ar2rsawseen

@ar2rsawseen ar2rsawseen commented Jul 27, 2026

Copy link
Copy Markdown
Member

Problem

api/api.js parses every POST body with formidable before the request is routed or authorized, and formidable writes multipart parts and raw application/octet-stream bodies to disk. So any POST carrying such a body left a temp file behind — including requests to paths no handler serves, and requests rejected during validation. Nothing removed them, so they accumulated in the OS temp directory.

Cleaning up at each rejection site does not work: rights.js alone rejects in 63 places across seven validators with no shared exit point, so an unauthenticated POST /i/apps/update would still leak.

Change

Uploads are refused by default. A plugin declares the paths where it actually reads params.files:

plugins.uploadPaths.push({path: "/i/license/upload"});
plugins.uploadPaths.push({path: "/i/crash_symbols/add_symbol", raw: true});

Mirrors the existing plugins.ttlCollections registry. init() requires every plugin's api.js well before the server listens, so declarations are always in place. Core declares /i/apps/{create,update}; star-rating declares /i/feedback/{upload,logo}.

  • Matching is exact, after the installation subpath is stripped — so declaring /i (the SDK write endpoint, which carries the app-user picture) does not reopen the whole /i tree. POST /i/anything writes nothing.
  • raw declares that an endpoint reads an application/octet-stream body. Without it the octetstream parser is left out, which is the only way to stop a raw body reaching disk — that parser calls _newFile() directly and never consults filter.
  • Non-file parsers stay enabled throughout, so urlencoded and JSON bodies still populate params.qstring unchanged.

Whatever does get written is removed when the response is done with, on both finish and close — an upload aborted mid-stream leaves a partial file that finish never sees. Handlers that consume an upload still unlink it themselves, so in the normal case this is a no-op; what it catches is every request rejected before reaching a handler, with no rejection-site enumeration.

Cleanup works off a snapshot of the paths formidable produced, taken at parse time before any handler runs — not off params.files. That matters because crash_symbolication repoints params.files.symbols.path at files shipped with the plugin when serving populator data; those must never be deleted, and a snapshot makes that impossible by construction rather than by directory comparison.

No new configuration: config.sample.js and configextender.js are untouched by this branch.

Ordering prerequisite

Cleanup at response time is only safe because handlers no longer use their temp file afterwards. iconUpload in api/parts/mgmt/apps.js was not awaited before common.returnOutput, and jimp opened the file after the response — it now waits, with a rejection handler since iconUpload can reject on a bad MIME type. The two equivalents in the enterprise plugins (addAppUserPicture, and the cohorts long task) are handled in the companion PR.

Compatibility

⚠️ Merges together with Countly/countly-enterprise-plugins#3324, which declares the enterprise upload paths. Landing this alone would make those endpoints silently receive no file.

Note init() requires every plugin directory present, including disabled ones, so a disabled plugin's path is still declared — harmless, since the endpoint will not route and the file is discarded at response time.

No dependency change — formidable stays at 2.1.3.

Verification

test/unit-tests/api.utils.upload-temp.js — 21 unit tests: every declared endpoint allowed; undeclared paths under /i refused (/i/apps/delete, /i/bulk, /i/whatever); exact matching (/i allowed, /i/anything refused); empty and absent registries refuse everything; raw honoured only where declared; subdirectory installs; near-misses like /iffy; and cleanup, including an explicit test that a path a handler substituted after parsing is left alone while the real upload is removed.

Verified end-to-end against a real formidable@2.1.3 server mirroring the patched handler — 14 checks asserting both halves, that nothing is written for undeclared paths and that nothing survives the response even on declared paths whose handler never consumed the file: multipart and octet-stream to unrouted paths, /i/apps/delete, /i/garbage and /o/export write nothing; octet-stream to /i and /i/apps/update write nothing; /i, /i/apps/update and /i/crash_symbols/* receive their file and it is gone afterwards; urlencoded fields still parse on a blocked path.

🤖 Generated with Claude Code

POST bodies are parsed with formidable before a request is routed or
authorized. formidable writes multipart parts and raw
application/octet-stream bodies to disk, so any POST carrying such a
body leaves a temp file behind - including requests that never reach a
handler, and requests rejected during validation. Nothing removed those
files afterwards, so they accumulated in the OS temp directory for the
lifetime of the process.

- skip the file writing parsers for paths that cannot reach an upload
  handler. Countly routes API requests under /i and /o only, so bodies
  sent anywhere else can never be claimed. Both a filter and dropping
  the octetstream plugin are needed: formidable's octetstream plugin
  creates its file directly and never consults filter.
- write upload temp files to a dedicated directory rather than straight
  into the OS temp directory, so they are attributable and separable
  from unrelated files. Configurable via api.uploadDir.
- periodically remove unclaimed upload temp files from that directory.
  Removal is age based, so it cannot race a request that is still using
  its file, including handlers that keep reading after responding.
  Configurable via api.uploadTempMaxAge.

Existing upload endpoints are unaffected: every path that consumes
params.files lives under /i, and the installation subpath is stripped
before matching so subdirectory installs keep working.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 15:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Replaces the periodic sweep with prevention at the API layer plus removal
at the points where a request is rejected before dispatch.

- multipart files are now only written under /i. Every endpoint that
  consumes params.files lives there; nothing under /o accepts an upload.
- raw application/octet-stream bodies are now only written for the
  crash_symbols endpoints that actually read them, which is also why
  api/api.js already buffers those requests differently. Previously any
  octet-stream body anywhere created a file, since formidable's
  octetstream plugin creates it directly and never consults filter.
- files parsed out of a request that is then rejected are removed at the
  rejection site: a missing app_key or device_id, a path no handler
  serves, and via /sdk/cancel a bad app key or failed checksum. Nothing
  has been dispatched at those points, so nothing can be mid-read.

Removal stays away from response teardown, and only touches files
directly inside the upload directory, because some handlers keep using
their temp file after responding and crash_symbolication repoints
params.files[x].path at files shipped with the plugin.

api.uploadDir is kept so the files are attributable and an operator can
target one directory. api.uploadTempMaxAge and the sweep are gone.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 16:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ar2rsawseen ar2rsawseen changed the title fix(api): bound lifetime of upload temp files fix(api): prevent uploads reaching disk where no endpoint reads them Jul 27, 2026
POST bodies are parsed with formidable before a request is routed or
authorized, so any multipart or application/octet-stream body was written
to disk regardless of whether an endpoint would ever read it. Enumerating
the places a request can be rejected does not cover this: rights.js alone
rejects in 63 places, with no shared exit point.

Uploads are now refused by default and allowed only where a plugin says
it reads params.files:

    plugins.uploadPaths.push({path: "/i/license/upload"});
    plugins.uploadPaths.push({path: "/i/crash_symbols/add_symbol", raw: true});

Paths match exactly, after the installation subpath is stripped, so
declaring the SDK write endpoint does not reopen the whole /i tree. `raw`
declares that the endpoint reads an application/octet-stream body; without
it the octetstream parser is left out, which is the only way to stop a raw
body reaching disk since that parser creates its file directly and never
consults `filter`.

Whatever does get written is removed when the response is done with, on
both 'finish' and 'close' - an upload aborted mid-stream leaves a partial
file that 'finish' never sees. Handlers that consume an upload still unlink
it themselves; this catches every request rejected before reaching one.

Only files directly inside the upload directory are removed, because
crash_symbolication repoints params.files[x].path at files shipped with
the plugin when serving populator data.

For this to be safe, apps.js now waits for iconUpload before responding:
it was not awaited, and jimp opened the temp file after the response.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 18:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ar2rsawseen ar2rsawseen changed the title fix(api): prevent uploads reaching disk where no endpoint reads them fix(api): refuse file uploads unless a plugin declared the path Jul 27, 2026
The dedicated upload directory existed only so cleanup could tell a file
formidable created from a path a handler substituted, by comparing the
directory. Recording the paths formidable produced, before any handler
runs, does that more directly and needs no directory, no configuration and
no startup mkdir.

- drops api.uploadDir and its config.sample.js and configextender entries,
  both files are now untouched by this branch
- trackUploads snapshots the paths at parse time, discardUploads removes
  exactly those, so a handler repointing params.files[x].path cannot
  redirect it at a file that must survive
- upload-temp.js no longer needs os or path

Operationally nothing is lost: /tmp has to be a writable mount anyway
under readOnlyRootFilesystem, so emptyDir with medium Memory covers
uploads along with report PDFs and browser profiles, without a second
mount or a config knob to keep in sync.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 18:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The single fixture listed all sixteen upload paths under a comment saying
they were what core and the plugins declare, which reads as if this repo
registers /i. It does not: there is no users plugin here, so nothing in this
repo reads an upload on /i, and the declaration lives in the users plugin in
countly-enterprise-plugins.

Splits the fixture into what this repo actually declares and what the
enterprise plugins declare, and asserts the coupling both ways: the
enterprise endpoints are refused with only this repo installed, and allowed
once their declarations are present.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 19:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ar2rsawseen

Copy link
Copy Markdown
Member Author

Merge order update: the companion PR Countly/countly-enterprise-plugins#3324 now initialises plugins.uploadPaths defensively before pushing to it, so its declarations are inert against a core that predates the registry.

That means the two no longer have to land in the same window — merge the enterprise PR first, then this one. Landing this one first would still leave the twelve enterprise upload endpoints undeclared and therefore refused, so the order matters even though the coupling is no longer simultaneous.

For context on why: with no defensive init, plugins.uploadPaths.push(...) threw at plugin load against an older core, and pluginManager responds to a throwing plugin by skipping it and marking it disabled in the database — so nine plugins switched off rather than one endpoint misbehaving.

The fixture restated the declarations, so it could not fail: deleting or
mistyping a push in the source left every assertion passing, and listing the
enterprise paths alongside this repo's read as if this repo declared them.

Reads the declarations out of the source instead, and checks the things a
restated list cannot:

- the declared set matches an expected snapshot, so removing an endpoint is a
  visible diff rather than an endpoint quietly refusing uploads
- each declaration is a bare path under /i or /o, with no trailing slash,
  query string or wildcard
- each declaration is actually matched, so one the matcher can never match -
  a trailing slash, say - fails instead of silently refusing its own uploads
- no declaration permits a deeper path

The behavioural tests now use a synthetic registry, since the matcher does
not care which paths happen to exist and realistic looking data was what made
the old fixture misleading.

Verified by breaking a declaration three ways - trailing slash, missing
leading slash, deleted - and confirming each fails.

Loading the plugins would be more faithful than a scan, but a plugin api.js
cannot be required in isolation: it pulls in common.js, the config and the
database drivers, and a stub would have to cover some 28 pluginManager
members across 37 plugins. Noted in the file that only the integration suite
can check that every endpoint reading params.files has a declaration.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 19:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

This is the check that catches a declaration whose path shape is wrong. A
declaration that matches no request refuses the upload it was meant to
permit, and nothing else reveals it: the enterprise repo declared
/i/surveys/create where clients request /i/surveys/nps/create, and only
comparing the declarations against the URLs the tests really post to found it.

Scans the plugin specs for a .post whose chain also has an .attach, bounding
each request at the next .post so an upload in one test is not attributed to
the next one, and ignoring commented out lines - plugins/push/tests.js has a
disabled upload suite that otherwise looks real.

This repo has no active upload test, so the check passes trivially today.
/i/apps/* and /i/feedback/* have nothing exercising a real multipart POST,
which is itself worth fixing; the check is here so it starts guarding as soon
as one is added.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 28, 2026 11:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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