Skip to content

server-backend: no authentication, no rate limiting, no TLS, which is why everything is published on loopback #72

Description

@otsobide

What happens

collapse-server-backend accepts any request that reaches it. There is no
credential check anywhere in the router, no rate limiting, and no transport
security: it serves plain HTTP over a plain TCP listener. Anyone who can reach
the port can queue jobs (which cost CPU, RAM and disk), and anyone holding a job
id can download or delete that job.

This is not a discovery: it is documented in three places
(docs/threat_model.md measure 9, docs/server.md "Exposure", and the comments
in docker-compose.yml), and it is the stated reason every published port in
the repository is bound to 127.0.0.1. This issue exists to record the decision
and what changing it would cost, not to argue it was wrong.

Two things are worth adding to what the docs already say, both verified against
v0.7.0.

The documented "put it behind something that authenticates" answer already
works for the CLI.
ureq sends HTTP Basic credentials from a URL's userinfo,
so a reverse proxy asking for a password needs no client change. Against a stub
that requires authentication:

$ collapse compress notes.txt --server http://user:secret@127.0.0.1:<port>
error: the server rejected the request (HTTP 401): authentication required

Authorization header seen by the server: ['Basic dXNlcjpzZWNyZXQ=']

The credential travels, and a rejection is reported legibly. What the client
cannot do is hold a bearer token, an API key header, or a client certificate.

One make target does not honour the loopback posture. make docker/run
publishes on every interface, unlike compose:

Makefile:97

docker/run: docker/build                       ## (help text elided)
	docker run --rm -p $(COLLAPSE_PORT):8000 $(IMAGE) $(ARGS)

-p 8000:8000 binds 0.0.0.0, and the image's ENTRYPOINT already pins
--host 0.0.0.0 inside the container, so the server's own loopback default is
not a second line of defence here. On a laptop on a cafe network, that command
puts an unauthenticated compression service on the LAN. Every other path
(docker/up, docker/aio, the compose file, smoke.sh) uses
127.0.0.1:<port>:8000.

Why it matters

For the intended deployment (a machine you own, on a network you trust, or
behind a proxy you control) the posture is coherent and everything downstream is
consistent with it. The risk is entirely in the gap between that and what
someone does on a Tuesday: change one port mapping, run make docker/run, or
put the web frontend on a LAN address because the browser has to reach it from
another machine. Publishing the web port publishes the API with it, since nginx
proxies /compress and /jobs, so there is no configuration in which :8080
is safe and :8000 is not.

The consequences of getting it wrong are resource exhaustion (the queue is
unbounded, jobs stage to disk, and the zip/7z backends buffer whole files in
memory) and disclosure: uploads and downloads travel in the clear, and a job id
is the only thing standing between a stranger and someone's archive.

Nothing here is urgent while the server is not shipped in releases. It becomes a
prerequisite the moment it is, or the moment anyone is told to run it on a host
with a public address.

Where it is

The router has no auth layer. 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))
        .layer(DefaultBodyLimit::max(max_upload_mb * 1024 * 1024))

DefaultBodyLimit is the only thing between a client and the machine. The
binary parses --host, --port, --max-upload-mb, --storage-dir,
--job-ttl-minutes and --shutdown-grace-seconds
(apps/server-backend/src/main.rs:14): there is no flag for a token, a
certificate or a key, and no TLS dependency in Cargo.toml.

Already written down:

  • docs/threat_model.md:179, "What the server does not defend against": no
    authentication and no rate limiting, no transport security, the web port
    exposes the API, uploads held in memory.
  • docs/server.md:533, "Exposure", including the point that job ids are random
    UUIDs but confer full access to their job.
  • docker-compose.yml, the comment above the backend's ports: block.

Why it is like that

The server was built for one job: let a front end hand a compression to another
machine you already control. Under that assumption authentication is the
network's job, and every layer of the design leans on it consistently (no CORS
layer, loopback binding, the desktop's warning in its servers panel, both front
ends defaulting to local compression). Adding a credential mechanism would have
meant inventing one, carrying it through collapse-remote, the CLI's flags, the
desktop's settings sheet and the web app's UI, all before anyone had asked to
expose the thing.

TLS in-process would have been a further step in the same direction: certificate
loading, renewal, and a second thing to get wrong, when a reverse proxy does it
better and is what the deployment docs already recommend.

What a fix looks like

Three routes, and they are not exclusive.

A. Keep the posture and close the gaps in it (cheapest, no protocol change).
Bind make docker/run to 127.0.0.1 like every other path does. State in
docs/server.md that Basic auth through a proxy works with the CLI today,
including its cost: the credential ends up in shell history, and in the
desktop's localStorage if the URL is saved as a server (see
apps/desktop/src/sources.js). This does not make the server safe to expose; it
makes the recommended way to expose it actually documented and verified.

B. A shared secret in the server. --auth-token plus
COLLAPSE_AUTH_TOKEN, checked by a small middleware layer over the API router.
Decisions that come with it:

  • /health must stay open (the container probes it every ten seconds, and the
    compose healthcheck has no way to hold a secret). /docs and
    /openapi.json are a judgement call: closing them protects nothing, but an
    open /docs advertises the service.
  • The client half is the larger part: collapse-remote needs to carry a
    credential (a Client struct holding server plus token, rather than more
    arguments on compress_path), the CLI needs --token or an environment
    variable, the desktop needs a field per server in its settings sheet and
    somewhere to keep it that is not localStorage, and the web app would have to
    prompt for it, since a static bundle cannot hold a secret.
  • The hand-written apps/server-backend/assets/openapi.json must gain the
    security scheme, and tests/openapi.rs will hold it to it.

A shared token is a real improvement over nothing and still has no revocation,
no per-client identity and no audit trail. Say so rather than calling it solved.

C. Rate limiting and quotas. Orthogonal to authentication and worth its own
thought: the single-consumer worker already bounds CPU, but nothing bounds queue
depth, total staged bytes, or requests per second. tower-governor or a
ConcurrencyLimitLayer covers the request side; a cap on queued jobs and on
total staging size covers the rest. Overlaps with #7, which is about the same
resources from the archive's side.

Not recommended: TLS in-process. axum-server with rustls is a small amount
of code and a large amount of operations (certificates, renewal, reloading). The
proxy recipes in docs/server.md already do it, and the client speaks HTTPS
today (ureq is built with rustls and webpki-roots in Cargo.lock), so
--server https://host works the moment something terminates TLS.

How to know it is fixed

For A:

  • make docker/run publishes on 127.0.0.1, and a comment says why.
  • docs/server.md documents the proxy-with-Basic-auth recipe end to end,
    including the CLI invocation and where the credential is stored.

For B, additionally:

  • apps/server-backend/tests/api.rs gains an_unauthenticated_request_is_401
    and a_request_with_the_token_is_accepted, plus a test that /health stays
    reachable without one.
  • apps/remote/tests/client.rs covers sending the credential and mapping a 401
    to a legible RemoteError::Rejected.
  • apps/server-backend/tests/openapi.rs covers the documented security scheme.
  • docs/threat_model.md measure 9 and docs/server.md "Exposure" are rewritten
    to describe what is now defended, and what still is not.

Related

  • docs/threat_model.md:179 and docs/server.md:533 already state this
    posture. Any fix must update both, and they should not start disagreeing with
    each other.
  • docs/architecture.md Roadmap lists "authentication and TLS for the server"
    as remaining work.
  • core: guard extraction against decompression bombs (size/ratio/count limits) #7 (decompression bombs) is the same machine's resources seen from the archive
    side.
  • The all-in-one container runs as root, unlike the split backend image, which
    drops to uid 10001 (docs/server.md, "Known limitations"). Related posture,
    separate issue.

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