Skip to content

server-backend: no extraction endpoint, so the web app can only compress #73

Description

@otsobide

What happens

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.

If the decision is A or B:

  • core: guard extraction against decompression bombs (size/ratio/count limits) #7 is closed first, or the endpoint enforces its own caps.
  • 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.

Related

  • core: guard extraction against decompression bombs (size/ratio/count limits) #7 (decompression bombs) is the blocker for options A and B, not a parallel
    concern.
  • docs/threat_model.md measure 8 explains why tar is the only envelope
    accepted today.
  • docs/server.md, "Known limitations": "No extraction over HTTP".
  • docs/architecture.md, the collapse-server-frontend section.
  • The buffering issue in this batch (zip and 7z read whole entries into memory)
    is the memory half of the same question.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions