security: close the holes a session or a stranger could walk through

DELETE /api/media/{id} joined the raw, percent-decoded id onto uploads/,
so ..%2Fdiary.db deleted the database. Ids are now parsed as uuids before
they reach the filesystem, and a file is only removed when its row was.

Import panicked on a link like %aé: percent_decode sliced the string
between the bytes of a character. Hex digits are now read as bytes. The
SFTP write_at offset is checked before it is cast, so an offset near
u64::MAX no longer wraps past the size limit and panics the session.

The global body limit was the upload ceiling, about a gigabyte, and
applied to the login too. Only the upload routes take that much now;
other bodies stop at 16 MiB and the login at 16 KiB.

Writes a browser says came from another origin are refused, which
SameSite=Lax alone does not cover for sibling subdomains or for
multipart forms. Logout only locks the vault for a caller who is
logged in, so a cross-site form can no longer shut it.

Login and vault attempts go through one at a time, so a burst of
parallel guesses cannot all pass the throttle before the first failure
is recorded, and parallel Argon2 runs cannot pile up.

Sealed files are served with no-store instead of a year of cache, so
their plaintext does not outlive :lock in the browser. The SFTP host key
is created 0600 rather than narrowed after being written.

SIGTERM is handled: as PID 1 in the container it was ignored, and every
docker stop ended in SIGKILL. Requests get eight seconds to finish, the
pool is closed so the WAL is checkpointed, and compose runs tini.
This commit is contained in:
nvrl committed 2026-10-20 01:13:03 +02:00
1 parent cc7d05aaf2
commit 082f2c8602
13 files changed
+335 -66

No files matched your search

+19 -5
View File
@@ -11,16 +11,29 @@ pub mod ssh_keys;
pub mod vault;
use axum::{
extract::DefaultBodyLimit,
routing::{delete, get, post, put},
Router,
};
use crate::state::AppState;
pub fn api_router() -> Router<AppState> {
/// Anything that is not an upload: a document body, a card, a password.
/// The size of the largest document anyone would write by hand, many times
/// over, and the same ceiling the SFTP mount puts on one.
const JSON_LIMIT: usize = 16 * 1024 * 1024;
/// The only body read before anyone has proved who they are.
const LOGIN_LIMIT: usize = 16 * 1024;
/// `max_upload` applies to the routes that take files and to nothing else, so
/// the upload ceiling is not also how much a stranger may make the server read.
pub fn api_router(max_upload: usize) -> Router<AppState> {
// Multipart framing on top of the files themselves.
let uploads = DefaultBodyLimit::max(max_upload.saturating_add(1024 * 1024));
Router::new()
.route("/me", get(session::me))
.route("/login", post(session::login))
.route("/login", post(session::login).layer(DefaultBodyLimit::max(LOGIN_LIMIT)))
.route("/logout", post(session::logout))
.route("/export", get(export::export))
.route("/backup", get(backup::status).post(backup::run))
@@ -39,8 +52,8 @@ pub fn api_router() -> Router<AppState> {
.route("/nodes/{id}", put(nodes::update))
.route("/nodes/{id}", delete(nodes::remove))
.route("/nodes/{id}/move", post(nodes::move_node))
.route("/nodes/{id}/import", post(import::import))
.route("/nodes/{id}/files", post(media::upload_into))
.route("/nodes/{id}/import", post(import::import).layer(uploads))
.route("/nodes/{id}/files", post(media::upload_into).layer(uploads))
.route("/nodes/{id}/encrypt", post(seal::encrypt))
.route("/nodes/{id}/decrypt", post(seal::decrypt))
.route("/vault", get(vault::status).post(vault::setup).put(vault::change_password))
@@ -48,10 +61,11 @@ pub fn api_router() -> Router<AppState> {
.route("/vault/lock", post(vault::lock))
.route("/nodes/{id}/share", post(share::enable))
.route("/nodes/{id}/share", delete(share::disable))
.route("/media", post(media::upload).get(media::list))
.route("/media", post(media::upload).layer(uploads).get(media::list))
.route("/media/{id}", get(media::serve).delete(media::remove))
.route("/share/{token}/{key}", get(share::read))
.route("/share/{token}/{key}/media/{id}", get(share::serve_media))
.route("/ssh-keys", get(ssh_keys::list).post(ssh_keys::add))
.route("/ssh-keys/{id}", delete(ssh_keys::remove))
.layer(DefaultBodyLimit::max(JSON_LIMIT))
}