Add custom token support
This commit is contained in:
@@ -6,248 +6,54 @@ their own, and are served back as inert attachments.
|
||||
Built for sharing large binaries with friends — a 400 MB Godot export is a
|
||||
normal day here — without a `tmp/` folder that grows forever.
|
||||
|
||||
- One static Go binary. No database, no dependencies outside the standard
|
||||
library, no CSS or JavaScript build step.
|
||||
- One static Go binary, standard library only. No database, no CSS or
|
||||
JavaScript build step, nothing to install.
|
||||
- Anyone who can reach the page may upload, within a size cap and a lifetime.
|
||||
- Named tokens raise those limits and unlock custom URLs.
|
||||
- Named tokens raise those limits, unlock custom URLs, and can grant admin.
|
||||
- Everything expires unless a token says otherwise.
|
||||
|
||||
## Building
|
||||
## Quickstart
|
||||
|
||||
```
|
||||
go build -o send .
|
||||
./send token add me --token - --vanity --admin # type a passphrase, or omit
|
||||
./send --port 8080 # --data defaults to ./data
|
||||
```
|
||||
|
||||
Go 1.25 or newer. There is nothing to install: the frontend is embedded in the
|
||||
binary.
|
||||
Open <http://localhost:8080>, click **Log in**, paste the token. That's it.
|
||||
|
||||
## Running
|
||||
`--token -` reads a passphrase you choose from standard input; leave the flag
|
||||
off and a random one is generated and printed once. Either way `--data` must
|
||||
name the *same directory* the server runs with, or the server will never see
|
||||
the token.
|
||||
|
||||
```
|
||||
./send --data /var/lib/send --listen 127.0.0.1:8080
|
||||
```
|
||||
|
||||
It binds to loopback by default and expects to sit behind a reverse proxy that
|
||||
terminates TLS. Make sure the proxy does not buffer request bodies and does not
|
||||
impose its own upload size limit, or large uploads will die before they arrive.
|
||||
|
||||
Options take **one hyphen with a single letter** and **two with a full word**:
|
||||
`-s 4GiB` and `--max-size=4GiB` are the same option, `-max-size` is an error.
|
||||
Every option can also be set from the environment as `SEND_MAX_SIZE` and so on.
|
||||
`./send --help` lists them all.
|
||||
|
||||
`--port` is a convenience over `--listen`: it replaces only the port, so
|
||||
`./send -p 9000` listens on `127.0.0.1:9000` and `--listen 0.0.0.0 -p 9000`
|
||||
listens on all interfaces.
|
||||
|
||||
| Option | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `-l`, `--listen` | `127.0.0.1:8080` | address to listen on |
|
||||
| `-p`, `--port` | — | port to listen on, replacing the one in `--listen` |
|
||||
| `-d`, `--data` | `./data` | data directory |
|
||||
| `-b`, `--base-url` | `/` | path prefix when mounted under a subdirectory |
|
||||
| `-u`, `--public-url` | — | absolute base URL used in generated links |
|
||||
| `-s`, `--max-size` | `2GiB` | per-upload cap; `unlimited` to remove it |
|
||||
| `-e`, `--max-expiry` | `3d` | longest lifetime a caller may ask for; `never` to remove the cap |
|
||||
| `--default-expiry` | `3d` | lifetime applied when the caller does not ask |
|
||||
| `--max-total-bytes` | `unlimited` | whole-store quota |
|
||||
| `--min-free-bytes` | `1GiB` | refuse uploads below this much free disk |
|
||||
| `--tokens` | `<data>/tokens.json` | token file |
|
||||
| `--trusted-proxy` | — | networks whose `X-Forwarded-For` is believed |
|
||||
| `--sweep-interval` | `1m` | how often expired files are removed |
|
||||
| `--upload-rate` | `60` | uploads per hour per client |
|
||||
| `--upload-burst` | `10` | uploads allowed back-to-back |
|
||||
| `--max-concurrent` | `8` | uploads in flight at once |
|
||||
|
||||
Sizes accept `2GiB`, `500MB`, `4G` or a plain byte count. Durations accept `3d`,
|
||||
`12h`, `90m`, `1w` or `never`.
|
||||
|
||||
## Tokens
|
||||
|
||||
A token grants its own limits. Anything left unset is inherited from the
|
||||
server's defaults, so a bare token behaves exactly like the anonymous tier
|
||||
except that it may claim custom names.
|
||||
|
||||
```
|
||||
./send token add thayol --vanity --max-size 8GiB --max-expiry never
|
||||
./send token list
|
||||
./send token rm thayol
|
||||
```
|
||||
|
||||
The token is printed once and never again; only its SHA-256 is stored. Send it
|
||||
as `Authorization: Bearer <token>`, or paste it into the form's token field.
|
||||
|
||||
`tokens.json` may also be edited by hand; the server picks up changes on its own
|
||||
and on `SIGHUP`. It must stay mode `0600` — the server refuses to start
|
||||
otherwise, since it holds credential material.
|
||||
|
||||
### Logging in
|
||||
|
||||
Open **Log in**, paste a token, and the browser keeps it until you log out. The
|
||||
header then shows who you are, the upload page shows your real limits, and
|
||||
**Administration** appears if the token is an admin one. Tick *stay logged in*
|
||||
and it survives a browser restart; leave it unticked and it dies with the
|
||||
browser, which is the right choice on a machine that is not yours.
|
||||
|
||||
The upload form also keeps a collapsed **Use a different token for this upload**
|
||||
field. That one applies to a single upload and never changes the session, so a
|
||||
quick upload under another token does not mean logging in and out.
|
||||
|
||||
The session cookie is `HttpOnly`, so the page's own script cannot read it — the
|
||||
server resolves the session and renders it. That is deliberately unlike
|
||||
`localStorage`, where any script injected into the origin could read the token
|
||||
straight out. It is also `SameSite=Strict`, so no other site can make your
|
||||
browser upload or delete anything with it attached.
|
||||
|
||||
Nothing about the session involves JavaScript: every page states who you are
|
||||
because the server rendered it that way. Callers sending `Authorization: Bearer`
|
||||
are never given a cookie; an API client keeps its own credentials.
|
||||
|
||||
## Uploading
|
||||
|
||||
From the browser, just use the page. It works with JavaScript disabled; with it
|
||||
enabled you get a progress bar, drag-and-drop and a copy-link button.
|
||||
|
||||
From the command line, `POST /api/upload` with the file as the whole body:
|
||||
Uploading from a script:
|
||||
|
||||
```
|
||||
curl --data-binary @MyGame.zip \
|
||||
-H 'Content-Disposition: attachment; filename="MyGame.zip"' \
|
||||
-H 'Authorization: Bearer <token>' \
|
||||
-H 'Vanity: my-game' \
|
||||
-H 'Expiry: 7d' \
|
||||
-H 'Accept: application/json' \
|
||||
https://send.example.com/api/upload
|
||||
-H 'Vanity: my-game' -H 'Expiry: 7d' \
|
||||
http://localhost:8080/api/upload
|
||||
```
|
||||
|
||||
`Content-Disposition`, `Vanity` and `Expiry` are all optional. Without a vanity
|
||||
name you get a UUIDv4; vanity names require a token. These headers work with a
|
||||
`multipart/form-data` body too, where a non-empty form field of the same name
|
||||
overrides them. The reply carries the
|
||||
download URL and a **delete token**, shown exactly once:
|
||||
The reply carries the download URL and a delete token. Use a generated token
|
||||
for scripts — a chosen passphrase is verified with a deliberately slow
|
||||
derivation, which is wasted on every request a script makes.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-game",
|
||||
"url": "https://send.example.com/d/my-game",
|
||||
"expires": "2026-09-19T10:00:00Z",
|
||||
"delete_token": "…",
|
||||
"delete_url": "https://send.example.com/api/d/my-game/delete"
|
||||
}
|
||||
```
|
||||
In production, put it behind a reverse proxy that terminates TLS, set
|
||||
`--public-url`, and make sure the proxy neither buffers request bodies nor
|
||||
imposes its own upload limit.
|
||||
|
||||
The same endpoint accepts `multipart/form-data` from the web form. In that shape
|
||||
the `token`, `expiry` and `vanity` fields **must precede the file part** — the
|
||||
body is streamed, so the limits have to be known before the first byte of the
|
||||
file is accepted.
|
||||
## Options
|
||||
|
||||
## Downloading and deleting
|
||||
**Read `./send --help` rather than this file.** It lists every option with its
|
||||
default and its environment variable, and unlike a README it cannot drift out
|
||||
of date. `./send token --help` does the same for credentials.
|
||||
|
||||
| Route | |
|
||||
|---|---|
|
||||
| `GET /d/{id}` | the file, as an attachment; supports resuming |
|
||||
| `GET /i/{id}` | a page showing name, size, expiry, digest — and where a delete token is used |
|
||||
| `POST /api/d/{id}/delete` | delete, with `token=` in the form or `Authorization: Bearer` |
|
||||
| `GET /login`, `POST /login` | start a browser session with a token |
|
||||
| `POST /logout` | end it |
|
||||
| `GET /admin` | administration page; admin tokens only |
|
||||
| `GET /api/limits` | what the presented credential may do; for scripts, the pages do not use it |
|
||||
|
||||
Deleting accepts the object's delete token, the token that uploaded it, or any
|
||||
admin token.
|
||||
|
||||
The delete token is shown once, when the file is uploaded. To use it later,
|
||||
open the file's info page and expand **Remove this file** — that page is the
|
||||
link worth keeping, since it holds everything about the file including the way
|
||||
to withdraw it. Anyone whose own token already owns the file, or who is an
|
||||
admin, gets a plain button there instead of a field. A wrong token returns to
|
||||
the same page with the reason rather than to a generic error, and repeated
|
||||
failures are throttled per address; a correct token is never delayed by
|
||||
someone else's guessing.
|
||||
|
||||
## Administration
|
||||
|
||||
An admin token adds a page at `/admin`, linked from the header whenever the
|
||||
token you are using is one. It is the view that privilege is for: every stored
|
||||
file, whoever uploaded it, with a delete button on each row.
|
||||
|
||||
- Files, with size, owner, upload time and expiry, sortable by column. Expired
|
||||
files are not listed even if the sweeper has not reached them yet, since they
|
||||
are already gone as far as anything else is concerned.
|
||||
- Totals: how many files, how much is stored, how much of the quota is used and
|
||||
how much disk is left.
|
||||
- The configured tokens with their limits — names only. Hashes are never
|
||||
rendered, and there is no way to mint or revoke a token from the page.
|
||||
Anything that hands out credentials stays in the CLI, off the network.
|
||||
|
||||
Deleting from the table returns to the table. A non-admin gets `403` and never
|
||||
sees the link.
|
||||
|
||||
## Data directory
|
||||
|
||||
```
|
||||
<data>/
|
||||
objects/<id>/blob the bytes 0664
|
||||
objects/<id>/meta.json name, size, digest, expiry 0664
|
||||
tokens.json credential hashes 0600
|
||||
```
|
||||
|
||||
Objects and their metadata are group-writable and world-readable, so a second
|
||||
account or a cleanup script can manage them. `tokens.json` is the deliberate
|
||||
exception.
|
||||
|
||||
There is no index to corrupt: the in-memory index is rebuilt from `meta.json`
|
||||
files at startup, and an object directory without one is either mid-upload or
|
||||
the remains of a killed one, invisible either way and swept after 24 hours.
|
||||
|
||||
## Notes on the security posture
|
||||
|
||||
Worth knowing if you are going to run this somewhere real.
|
||||
|
||||
- **Uploads are never sized by what the client claims.** `Content-Length` is
|
||||
not consulted anywhere; the cap is enforced on bytes actually written, and the
|
||||
transfer is cut off the moment it is exceeded.
|
||||
- **Nothing served from `/d/` can execute.** `Content-Type` is always
|
||||
`application/octet-stream`, the disposition is always `attachment`, and the
|
||||
response carries `nosniff` and `default-src 'none'; sandbox`. Upload an HTML
|
||||
file and a browser will download it, not render it.
|
||||
- **Filenames are metadata, never paths.** Every path is built from a validated
|
||||
ID: lowercase, 2–64 characters, no separators, no traversal, no route names.
|
||||
The uploaded filename only reaches the `Content-Disposition` header, where the
|
||||
ASCII form is built from a character whitelist and the real name rides along
|
||||
in an RFC 5987 parameter.
|
||||
- **Object files are opened through an `os.Root`.** Because the data directory
|
||||
is group-writable, a planted symlink could otherwise redirect a read or a
|
||||
write outside it. It cannot.
|
||||
- **Expiry is checked on every read**, not only by the sweeper, so a stalled
|
||||
sweeper can never serve a file past its lifetime. A missing file and an
|
||||
expired one give identical 404s.
|
||||
- **Uploads are published atomically**: the blob is fsynced and renamed into
|
||||
place before the metadata that advertises it is written, also by rename. A
|
||||
crash at any point leaves something invisible rather than something broken.
|
||||
- **Vanity names are claimed before the body is read**, so a collision costs one
|
||||
round trip rather than a 2 GiB transfer.
|
||||
- **Slow uploads are fine, stalled ones are not.** There is no server-wide read
|
||||
timeout — any value large enough for a legitimate 2 GiB upload would be no
|
||||
protection — so the upload handler maintains a per-read deadline instead.
|
||||
- **`X-Forwarded-For` is ignored** unless the peer is a configured
|
||||
`--trusted-proxy`, and then only to skip further trusted hops.
|
||||
- **The session lives in an `HttpOnly`, `SameSite=Strict` cookie**, not in
|
||||
`localStorage`, so neither an injected script nor another website can get at
|
||||
it. `Secure` is set whenever the service knows it is being served over
|
||||
HTTPS — from `--public-url`, from a TLS connection, or from a trusted proxy's
|
||||
`X-Forwarded-Proto`.
|
||||
- **Every `POST` must be same-origin.** `SameSite` covers requests that need a
|
||||
cookie, but logging in needs none: without this check a hostile page could
|
||||
sign a visitor into an account it controls and collect what they upload next.
|
||||
Browsers label their own requests with `Sec-Fetch-Site`, falling back to
|
||||
`Origin`; a request carrying neither is not a browser and is allowed through,
|
||||
since a bearer token cannot be attached by a third party anyway.
|
||||
- **Failed credential attempts are throttled per address** — a wrong delete
|
||||
token or a wrong login — while correct ones are never delayed, because only
|
||||
failures consume the budget.
|
||||
- Rate limiting is per client address, with a separate bound on uploads in
|
||||
flight. Both are in memory and reset on restart.
|
||||
Options take **one hyphen with a single letter** and **two with a full word**:
|
||||
`-s 4GiB` and `--max-size=4GiB` are the same option; `-max-size` is an error.
|
||||
Every option also reads from `SEND_`-prefixed environment variables.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -256,7 +62,37 @@ go test ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
The tests cover the parts where a mistake would be expensive: size limits
|
||||
against a chunked body with no declared length, vanity collisions, expiry on
|
||||
read, traversal and reserved names, `Content-Disposition` for hostile filenames,
|
||||
delete authorisation, symlink escapes and crash debris.
|
||||
Layout: `main.go` and `token.go` are the CLI; `internal/config` parses options;
|
||||
`internal/store` is the object store; `internal/auth` is credentials;
|
||||
`internal/server` is the HTTP surface; `web/` holds the templates and assets,
|
||||
embedded at build time.
|
||||
|
||||
A few invariants worth knowing before changing anything:
|
||||
|
||||
- **Upload size is never taken from `Content-Length`.** It is enforced on bytes
|
||||
actually written, and the transfer is cut off the moment it is exceeded.
|
||||
- **Nothing served from `/d/` may execute.** Always `application/octet-stream`,
|
||||
always an attachment, always `nosniff` and `default-src 'none'; sandbox`.
|
||||
- **Filenames are metadata, never paths.** Every path is built from a validated
|
||||
ID. `store.CleanID` is the only function allowed to turn input into a path
|
||||
element, and object files are opened through an `os.Root`.
|
||||
- **Expiry is checked on every read**, not only by the sweeper, and a missing
|
||||
file and an expired one answer identically.
|
||||
- **Uploads are published atomically**: the blob is fsynced and renamed into
|
||||
place before the metadata that advertises it, also by rename. A crash leaves
|
||||
something invisible rather than something broken.
|
||||
- **Generated tokens are hashed with SHA-256; chosen ones get PBKDF2** with
|
||||
their own salt. A 256-bit random value has nothing to crack, while a
|
||||
passphrase is guessable and probably reused elsewhere. The derivation is
|
||||
memoised per process and rate limited, so it cannot be used as an amplifier.
|
||||
- **The session cookie is `HttpOnly` and `SameSite=Strict`**, and every `POST`
|
||||
must be same-origin — `SameSite` does not cover logging in, which needs no
|
||||
cookie to submit.
|
||||
- **The app pages and the download responses have different CSP policies.**
|
||||
The app one must permit `connect-src` or the upload script is blocked; the
|
||||
download one must grant nothing at all. Both are pinned by tests.
|
||||
|
||||
The tests cover the places where a mistake is expensive: size limits against a
|
||||
body with no declared length, vanity collisions, expiry on read, traversal and
|
||||
reserved names, `Content-Disposition` for hostile filenames, delete
|
||||
authorisation, symlink escapes, crash debris, CSRF, and credential handling.
|
||||
|
||||
Reference in New Issue
Block a user