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
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.
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.
What happens
collapse-server-backendaccepts any request that reaches it. There is nocredential 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.mdmeasure 9,docs/server.md"Exposure", and the commentsin
docker-compose.yml), and it is the stated reason every published port inthe repository is bound to
127.0.0.1. This issue exists to record the decisionand 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.
ureqsends 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:
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/runpublishes on every interface, unlike compose:
Makefile:97-p 8000:8000binds0.0.0.0, and the image'sENTRYPOINTalready pins--host 0.0.0.0inside the container, so the server's own loopback default isnot 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) uses127.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, orput 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
/compressand/jobs, so there is no configuration in which:8080is safe and
:8000is 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:137DefaultBodyLimitis the only thing between a client and the machine. Thebinary parses
--host,--port,--max-upload-mb,--storage-dir,--job-ttl-minutesand--shutdown-grace-seconds(
apps/server-backend/src/main.rs:14): there is no flag for a token, acertificate 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": noauthentication 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 randomUUIDs but confer full access to their job.
docker-compose.yml, the comment above the backend'sports: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, thedesktop'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/runto127.0.0.1like every other path does. State indocs/server.mdthat 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
localStorageif the URL is saved as a server (seeapps/desktop/src/sources.js). This does not make the server safe to expose; itmakes the recommended way to expose it actually documented and verified.
B. A shared secret in the server.
--auth-tokenplusCOLLAPSE_AUTH_TOKEN, checked by a small middleware layer over the API router.Decisions that come with it:
/healthmust stay open (the container probes it every ten seconds, and thecompose healthcheck has no way to hold a secret).
/docsand/openapi.jsonare a judgement call: closing them protects nothing, but anopen
/docsadvertises the service.collapse-remoteneeds to carry acredential (a
Clientstruct holding server plus token, rather than morearguments on
compress_path), the CLI needs--tokenor an environmentvariable, 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 toprompt for it, since a static bundle cannot hold a secret.
apps/server-backend/assets/openapi.jsonmust gain thesecurity scheme, and
tests/openapi.rswill 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-governoror aConcurrencyLimitLayercovers the request side; a cap on queued jobs and ontotal 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-serverwith rustls is a small amountof code and a large amount of operations (certificates, renewal, reloading). The
proxy recipes in
docs/server.mdalready do it, and the client speaks HTTPStoday (
ureqis built with rustls and webpki-roots inCargo.lock), so--server https://hostworks the moment something terminates TLS.How to know it is fixed
For A:
make docker/runpublishes on127.0.0.1, and a comment says why.docs/server.mddocuments 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.rsgainsan_unauthenticated_request_is_401and
a_request_with_the_token_is_accepted, plus a test that/healthstaysreachable without one.
apps/remote/tests/client.rscovers sending the credential and mapping a 401to a legible
RemoteError::Rejected.apps/server-backend/tests/openapi.rscovers the documented security scheme.docs/threat_model.mdmeasure 9 anddocs/server.md"Exposure" are rewrittento describe what is now defended, and what still is not.
Related
docs/threat_model.md:179anddocs/server.md:533already state thisposture. Any fix must update both, and they should not start disagreeing with
each other.
docs/architecture.mdRoadmap lists "authentication and TLS for the server"as remaining work.
side.
drops to uid 10001 (
docs/server.md, "Known limitations"). Related posture,separate issue.