You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The server compresses and nothing else. Its whole surface is:
GET /health
GET /docs
GET /openapi.json
POST /compress
GET /jobs/{job_id}
GET /jobs/{job_id}/download
DELETE /jobs/{job_id}
There is no /extract, so collapse-server-frontend offers no extract mode: a
browser can turn a file or a folder into an archive, and cannot turn an archive
back into anything. The CLI and the desktop app both extract, but always
locally, whatever destination is chosen for compression.
This is a decision that has not been made yet rather than a thing that is
broken. It is recorded as such in docs/architecture.md ("Extraction is not
offered: the backend compresses only, so there is nothing to call") and in docs/server.md's known limitations ("No extraction over HTTP").
Why it matters
The web app exists for "people who will not install anything"
(docs/architecture.md). Half of what Collapse does is unavailable to exactly
those people, and the asymmetry is not visible in the UI: there is no mode
switch that is greyed out, no note saying extraction happens elsewhere. A user
who drops a .zip on the page gets it compressed into a .zip.zip.
Nobody loses data over this and nothing is at risk. It is a product gap with a
security question attached, which is why it deserves a decision rather than a
quiet omission.
Where it is
apps/server-backend/src/lib.rs:137
let api = Router::new().route("/docs",get(routes::docs)).route("/openapi.json",get(routes::openapi)).route("/compress",post(routes::compress_create)).route("/jobs/{job_id}",get(routes::job_status).delete(routes::delete_job),).route("/jobs/{job_id}/download",get(routes::download))
The job model is compression-shaped throughout: Job carries algorithm, level, archive_name and an Envelope
(apps/server-backend/src/models.rs), the worker's only two branches are
"compress a file" and "unwrap a tar envelope and compress the tree"
(apps/server-backend/src/queue.rs:70), and JobStatus spells the middle state compressing, a string the CLI, the desktop and the web app all parse
(apps/remote/src/protocol.rs:36, apps/server-frontend/src/api.js:39).
The proxy path lists the surface twice more, and both would need the new path: apps/server-frontend/nginx.conf:27 and the proxied array in apps/server-frontend/vite.config.js.
Why it is like that
Two reasons, both good ones.
The first is the trust boundary. The server's one concession to untrusted input
today is the tar envelope, and docs/threat_model.md measure 8 spells out why
tar specifically: it does not compress, so --max-upload-mb also bounds what
lands on disk when it is unpacked. "A zip or 7z envelope would have introduced a
decompression bomb where there is none today, which is why it is not offered."
An extract endpoint is precisely that envelope, made general: it accepts an
arbitrary zip or 7z from an arbitrary browser and expands it on a shared
machine.
The second is that nobody has answered what the result should be. Compression
has an obvious shape (bytes in, one file out). Extraction produces a tree, and
HTTP has no way to hand back a tree.
What a fix looks like
Deciding is the work; the code is the easy part.
Option A: extract and hand back a tar.POST /extract stages the upload,
runs collapse_core::extract into <job>/tree/, tars the result, and GET /jobs/{id}/download serves it. Symmetric with the envelope that already
exists, and the smallest server change. The client then holds a tar it has to
unpack: the CLI and desktop would not use this (they extract locally, faster and
with no upload), so the only consumer is the browser, which needs a ustar reader to do anything useful with it. The repo has a hand-written ustar writer
(apps/server-frontend/src/tar.js); a reader is a comparable amount of code.
Alternatively the browser offers the tar as a plain download, which is a poor
answer for someone who came to avoid installing tools.
Option B: a listing plus per-entry download.GET /jobs/{id}/entries
returns the extracted tree, GET /jobs/{id}/entries/{index} serves one file.
Much better in a browser (pick what you want, save it, done) and it needs no new
client-side format code. Costs more endpoints, more state, more of the
hand-written OpenAPI document, and a new path-safety surface: entry indices
rather than names as the addressing scheme, so nothing a client sends becomes a
path component. That property is worth preserving deliberately, since it is what docs/architecture.md says keeps the staging layout safe today.
Option C: decide not to, and say so in the UI. Add a line to the web app
explaining that extraction happens on your own machine and pointing at the CLI
and the desktop app. Costs nothing, closes the question honestly, and can be
reversed.
What #7 means for A and B. Today the server accepts one archive format that
cannot expand. Both A and B accept formats that can, from clients that are not
identified (see the authentication issue), onto a disk with no quota and into
memory that is only bounded by mem_limit: 2g in the compose file. Concretely:
a 1 MB zip can expand to many gigabytes, extract_zip reads each entry fully
into memory before writing it
(apps/core/src/compression/zip.rs:120, and the same in apps/core/src/compression/sevenz.rs:111), and the upload cap says nothing
about any of it. That makes #7 a prerequisite for A and B rather than an
adjacent nice-to-have: the output-size, ratio and entry-count caps have to exist
in core, and the endpoint has to fail the job cleanly when they trip. The
staging directory and the reaper already bound how long the result lives, which
is the one half that is already solved.
An additional server-side limit is worth considering even after #7 lands: a
per-job cap on extracted bytes that is lower than the machine's, so one client
cannot fill the disk with a legitimate 100 GB archive.
How to know it is fixed
If the decision is C: docs/server.md, docs/architecture.md and the web app
itself say the same thing, and this issue closes.
apps/server-backend/tests/api.rs covers the full flow for the new endpoint,
round-tripping through the core compressors (the compress tests already do the
mirror image of this).
apps/server-backend/tests/security.rs gains a hostile archive per format
(traversal entry, symlink entry, oversized expansion) and asserts nothing
escapes the job's staging directory and the job fails.
apps/server-backend/tests/openapi.rs covers the new path, since it asserts
every documented path is routed.
apps/server-frontend/tests/ covers the new mode, and nginx.conf plus vite.config.js both proxy the new path (a test or a smoke check that catches
one being updated without the other would be worth its weight).
docs/server.md, docs/architecture.md and docs/threat_model.md are
updated together: the threat model's "the server extracts an untrusted
archive" section currently describes only the tar envelope.
What happens
The server compresses and nothing else. Its whole surface is:
There is no
/extract, socollapse-server-frontendoffers no extract mode: abrowser can turn a file or a folder into an archive, and cannot turn an archive
back into anything. The CLI and the desktop app both extract, but always
locally, whatever destination is chosen for compression.
This is a decision that has not been made yet rather than a thing that is
broken. It is recorded as such in
docs/architecture.md("Extraction is notoffered: the backend compresses only, so there is nothing to call") and in
docs/server.md's known limitations ("No extraction over HTTP").Why it matters
The web app exists for "people who will not install anything"
(
docs/architecture.md). Half of what Collapse does is unavailable to exactlythose people, and the asymmetry is not visible in the UI: there is no mode
switch that is greyed out, no note saying extraction happens elsewhere. A user
who drops a
.zipon the page gets it compressed into a.zip.zip.Nobody loses data over this and nothing is at risk. It is a product gap with a
security question attached, which is why it deserves a decision rather than a
quiet omission.
Where it is
apps/server-backend/src/lib.rs:137The job model is compression-shaped throughout:
Jobcarriesalgorithm,level,archive_nameand anEnvelope(
apps/server-backend/src/models.rs), the worker's only two branches are"compress a file" and "unwrap a tar envelope and compress the tree"
(
apps/server-backend/src/queue.rs:70), andJobStatusspells the middle statecompressing, a string the CLI, the desktop and the web app all parse(
apps/remote/src/protocol.rs:36,apps/server-frontend/src/api.js:39).The proxy path lists the surface twice more, and both would need the new path:
apps/server-frontend/nginx.conf:27and theproxiedarray inapps/server-frontend/vite.config.js.Why it is like that
Two reasons, both good ones.
The first is the trust boundary. The server's one concession to untrusted input
today is the tar envelope, and
docs/threat_model.mdmeasure 8 spells out whytar specifically: it does not compress, so
--max-upload-mbalso bounds whatlands on disk when it is unpacked. "A zip or 7z envelope would have introduced a
decompression bomb where there is none today, which is why it is not offered."
An extract endpoint is precisely that envelope, made general: it accepts an
arbitrary zip or 7z from an arbitrary browser and expands it on a shared
machine.
The second is that nobody has answered what the result should be. Compression
has an obvious shape (bytes in, one file out). Extraction produces a tree, and
HTTP has no way to hand back a tree.
What a fix looks like
Deciding is the work; the code is the easy part.
Option A: extract and hand back a tar.
POST /extractstages the upload,runs
collapse_core::extractinto<job>/tree/, tars the result, andGET /jobs/{id}/downloadserves it. Symmetric with the envelope that alreadyexists, and the smallest server change. The client then holds a tar it has to
unpack: the CLI and desktop would not use this (they extract locally, faster and
with no upload), so the only consumer is the browser, which needs a ustar
reader to do anything useful with it. The repo has a hand-written ustar writer
(
apps/server-frontend/src/tar.js); a reader is a comparable amount of code.Alternatively the browser offers the tar as a plain download, which is a poor
answer for someone who came to avoid installing tools.
Option B: a listing plus per-entry download.
GET /jobs/{id}/entriesreturns the extracted tree,
GET /jobs/{id}/entries/{index}serves one file.Much better in a browser (pick what you want, save it, done) and it needs no new
client-side format code. Costs more endpoints, more state, more of the
hand-written OpenAPI document, and a new path-safety surface: entry indices
rather than names as the addressing scheme, so nothing a client sends becomes a
path component. That property is worth preserving deliberately, since it is what
docs/architecture.mdsays keeps the staging layout safe today.Option C: decide not to, and say so in the UI. Add a line to the web app
explaining that extraction happens on your own machine and pointing at the CLI
and the desktop app. Costs nothing, closes the question honestly, and can be
reversed.
What #7 means for A and B. Today the server accepts one archive format that
cannot expand. Both A and B accept formats that can, from clients that are not
identified (see the authentication issue), onto a disk with no quota and into
memory that is only bounded by
mem_limit: 2gin the compose file. Concretely:a 1 MB zip can expand to many gigabytes,
extract_zipreads each entry fullyinto memory before writing it
(
apps/core/src/compression/zip.rs:120, and the same inapps/core/src/compression/sevenz.rs:111), and the upload cap says nothingabout any of it. That makes #7 a prerequisite for A and B rather than an
adjacent nice-to-have: the output-size, ratio and entry-count caps have to exist
in core, and the endpoint has to fail the job cleanly when they trip. The
staging directory and the reaper already bound how long the result lives, which
is the one half that is already solved.
An additional server-side limit is worth considering even after #7 lands: a
per-job cap on extracted bytes that is lower than the machine's, so one client
cannot fill the disk with a legitimate 100 GB archive.
How to know it is fixed
If the decision is C:
docs/server.md,docs/architecture.mdand the web appitself say the same thing, and this issue closes.
If the decision is A or B:
apps/server-backend/tests/api.rscovers the full flow for the new endpoint,round-tripping through the core compressors (the compress tests already do the
mirror image of this).
apps/server-backend/tests/security.rsgains a hostile archive per format(traversal entry, symlink entry, oversized expansion) and asserts nothing
escapes the job's staging directory and the job fails.
apps/server-backend/tests/openapi.rscovers the new path, since it assertsevery documented path is routed.
apps/server-frontend/tests/covers the new mode, andnginx.confplusvite.config.jsboth proxy the new path (a test or a smoke check that catchesone being updated without the other would be worth its weight).
docs/server.md,docs/architecture.mdanddocs/threat_model.mdareupdated together: the threat model's "the server extracts an untrusted
archive" section currently describes only the tar envelope.
Related
concern.
docs/threat_model.mdmeasure 8 explains why tar is the only envelopeaccepted today.
docs/server.md, "Known limitations": "No extraction over HTTP".docs/architecture.md, the collapse-server-frontend section.is the memory half of the same question.