security(/magic): no cache + no referer + no index
This commit is contained in:
@@ -31,11 +31,15 @@ use askama::Template;
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE, header::LOCATION},
|
http::{
|
||||||
|
HeaderMap, HeaderValue, StatusCode,
|
||||||
|
header::{CACHE_CONTROL, CONTENT_TYPE, HeaderName, LOCATION, PRAGMA, REFERRER_POLICY},
|
||||||
|
},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use tower_http::set_header::SetResponseHeaderLayer;
|
||||||
|
|
||||||
use crate::application::services::auth_application_service::{
|
use crate::application::services::auth_application_service::{
|
||||||
MagicLinkRedeemResult, MagicLinkRedemption,
|
MagicLinkRedeemResult, MagicLinkRedemption,
|
||||||
@@ -57,10 +61,68 @@ use crate::interfaces::middleware::rate_limit::extract_client_ip;
|
|||||||
/// recipient email is looked up server-side from the (expired/used)
|
/// recipient email is looked up server-side from the (expired/used)
|
||||||
/// token row — no PII in the URL — and the rate limits attached to
|
/// token row — no PII in the URL — and the rate limits attached to
|
||||||
/// `POST /api/auth/magic-link/send` apply identically.
|
/// `POST /api/auth/magic-link/send` apply identically.
|
||||||
|
///
|
||||||
|
/// **Cache-Control: no-store** applies to every response from this
|
||||||
|
/// router. Magic-link responses are auth-state-sensitive in three
|
||||||
|
/// distinct ways and none of them should ever be persisted by a
|
||||||
|
/// browser or intermediate proxy:
|
||||||
|
///
|
||||||
|
/// 1. The 410 / cross-browser-confirm pages reveal *that the token
|
||||||
|
/// existed in some state*. Caching them across users of a shared
|
||||||
|
/// machine is an information leak.
|
||||||
|
/// 2. The successful-redemption 302 sets `oxicloud_access` /
|
||||||
|
/// `oxicloud_refresh` / `oxicloud_csrf` cookies. A cached redirect
|
||||||
|
/// response could replay those cookies in a wrong session context.
|
||||||
|
/// 3. The resend-confirmation page can be re-submitted; serving a
|
||||||
|
/// stale copy from cache could mask a fresh state on the server.
|
||||||
|
///
|
||||||
|
/// `Pragma: no-cache` is added alongside for HTTP/1.0-era proxies
|
||||||
|
/// that ignore `Cache-Control` — harmless on modern stacks, defensive
|
||||||
|
/// against the long tail.
|
||||||
|
///
|
||||||
|
/// **Referrer-Policy: no-referrer** overrides the global
|
||||||
|
/// `strict-origin-when-cross-origin`. The magic-link URL itself
|
||||||
|
/// contains the secret in the path; even "origin only" disclosure to
|
||||||
|
/// a third party narrows the bearer's anonymity. Today the templates
|
||||||
|
/// only carry a same-origin `<a href="/">` link, but defense-in-depth
|
||||||
|
/// closes the door against any future external reference.
|
||||||
|
///
|
||||||
|
/// **X-Robots-Tag: noindex, nofollow** so search engines never index
|
||||||
|
/// a leaked magic-link URL (e.g. one that ended up in a wiki page or
|
||||||
|
/// pastebin). Crawlers that respect the directive skip both the
|
||||||
|
/// indexing and the link-following side effect.
|
||||||
|
///
|
||||||
|
/// Global security headers (CSP / X-Frame-Options /
|
||||||
|
/// X-Content-Type-Options / Permissions-Policy) come from the
|
||||||
|
/// app-wide layer in `main.rs`; this router doesn't re-set them.
|
||||||
|
///
|
||||||
|
/// **Why no CSRF on the resend POST?** The endpoint takes no body
|
||||||
|
/// and no auth — only the token in the URL path. A cross-site form
|
||||||
|
/// submission would dispatch a magic-link mail to the *registered
|
||||||
|
/// recipient* (which the attacker has no influence over), capped by
|
||||||
|
/// the per-IP and per-target-email rate limits. There's no side
|
||||||
|
/// effect the attacker can direct anywhere they benefit from, so a
|
||||||
|
/// CSRF token would protect nothing.
|
||||||
pub fn magic_link_routes() -> Router<Arc<AppState>> {
|
pub fn magic_link_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/magic/v1/{token}", get(redeem_magic_link))
|
.route("/magic/v1/{token}", get(redeem_magic_link))
|
||||||
.route("/magic/v1/{token}/resend", post(resend_magic_link))
|
.route("/magic/v1/{token}/resend", post(resend_magic_link))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("no-store, no-cache, must-revalidate, max-age=0"),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
PRAGMA,
|
||||||
|
HeaderValue::from_static("no-cache"),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
REFERRER_POLICY,
|
||||||
|
HeaderValue::from_static("no-referrer"),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
HeaderName::from_static("x-robots-tag"),
|
||||||
|
HeaderValue::from_static("noindex, nofollow"),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
Reference in New Issue
Block a user