Files
narl-workspace/src/routes/session.rs
T
nvrl 082f2c8602 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.
2026-10-20 01:13:03 +02:00

77 lines
2.2 KiB
Rust

use axum::{
extract::State,
http::{header, HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use serde::Deserialize;
use serde_json::json;
use crate::{
auth::{self, Session},
throttle::FREE_ATTEMPTS,
error::{AppError, AppResult},
state::AppState,
};
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
pub async fn login(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<LoginRequest>,
) -> AppResult<impl IntoResponse> {
let attempt = state.login_throttle.begin().map_err(|wait| {
tracing::warn!("login refused, {wait}s of backoff remaining");
AppError::TooManyRequests(wait)
})?;
if !auth::credentials_match(&state.config, &body.username, &body.password) {
let failures = attempt.failed();
// Worth a mail once someone is past guessing and into trying.
if failures > FREE_ATTEMPTS {
state.notify.note_failed_logins(failures).await;
}
return Err(AppError::Unauthorized);
}
attempt.succeeded();
let user_agent = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");
state.notify.note_login(user_agent).await;
let token = auth::issue_token(&state.config);
Ok((
StatusCode::OK,
[(
header::SET_COOKIE,
auth::session_cookie(&state.config, &token),
)],
Json(json!({ "user": state.config.username })),
))
}
pub async fn logout(session: Option<Session>, State(state): State<AppState>) -> impl IntoResponse {
// Leaving is the moment the vault should not outlive — but only the owner
// leaving: anyone may clear a cookie they do not have, and that must not
// be a way to shut the vault on the one who does.
if session.is_some() {
state.vault.lock();
}
(
StatusCode::OK,
[(header::SET_COOKIE, auth::clear_cookie(&state.config))],
Json(json!({ "ok": true })),
)
}
pub async fn me(_: Session, State(state): State<AppState>) -> impl IntoResponse {
Json(json!({ "user": state.config.username }))
}