1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

security: fix two Codex review findings + add port option

Fix 1 — NixOS: store/ dir missing from ReadWritePaths
  archive_path points to .archivr/; captures write artifacts to the
  sibling store/ directory. Whitelisting only .archivr/ caused POST
  /captures to fail under ProtectSystem=strict.
  Fix: use builtins.dirOf a.path to whitelist the parent, which
  contains both .archivr/ and store/.

Fix 2 — Rust: X-Forwarded-For was unconditionally trusted
  An attacker could send a different IP on every login attempt,
  bypassing LOGIN_MAX_ATTEMPTS entirely.
  Fix: use ConnectInfo<SocketAddr> (via into_make_service_with_connect_info)
  as the primary rate-limit key; XFF is trusted only when the TCP peer
  is loopback (i.e. a local reverse proxy). Tests without a real socket
  fall back to XFF unchanged.

Port configuration:
  NixOS module: split 'bind' string into separate 'listenAddress'
  (default 127.0.0.1) and 'port' (default 8080) options. openFirewall
  now uses cfg.port directly instead of parsing it from the bind string.
  README NixOS example updated accordingly.
This commit is contained in:
TheGeneralist 2026-06-29 20:32:08 +02:00
parent 7ed7cb882f
commit 71e176cbd1
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
4 changed files with 70 additions and 27 deletions

View file

@ -63,6 +63,6 @@ async fn main() -> Result<()> {
let listener = tokio::net::TcpListener::bind(addr).await?;
println!("archivr-server listening on http://{addr}");
axum::serve(listener, app).await?;
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
Ok(())
}

View file

@ -21,7 +21,7 @@
use std::{
collections::{HashMap, VecDeque},
net::IpAddr,
net::{IpAddr, SocketAddr},
path::PathBuf,
sync::Arc,
time::{Duration, Instant},
@ -31,7 +31,7 @@ use parking_lot::Mutex;
use archivr_core::{archive, capture, database};
use axum::{
Json, Router,
extract::{Path, Query, Request, State},
extract::{ConnectInfo, Path, Query, Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
@ -165,12 +165,39 @@ async fn login_rate_limit(
}
fn extract_client_ip(req: &Request) -> IpAddr {
req.headers()
// Attempt to read the real peer address injected by
// `into_make_service_with_connect_info` in main.rs.
let peer_ip: Option<IpAddr> = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ci| ci.0.ip());
match peer_ip {
// Peer is a loopback address → the connection came from a local
// reverse proxy (nginx/caddy on the same host). Trust the first
// address in X-Forwarded-For as the real client IP.
Some(peer) if peer.is_loopback() => req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse().ok())
.unwrap_or(IpAddr::from([127, 0, 0, 1]))
.unwrap_or(peer),
// Peer is a real address → use it directly; ignoring X-Forwarded-For
// prevents header-spoofing attacks.
Some(peer) => peer,
// No ConnectInfo present (unit tests using .oneshot() without a real
// socket). Fall back to XFF for test compatibility.
None => req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse().ok())
.unwrap_or(IpAddr::from([127, 0, 0, 1])),
}
}
/// Build the Axum router from a pre-constructed `AppState`.

View file

@ -154,7 +154,8 @@ enable the service:
{
services.archivr-server = {
enable = true;
bind = "127.0.0.1:8080"; # loopback only; put nginx/caddy in front for TLS
# listenAddress defaults to "127.0.0.1" (loopback only)
# port defaults to 8080
archives = [
{ id = "personal"; label = "Personal"; path = "/srv/archivr/personal/.archivr"; }
{ id = "work"; label = "Work"; path = "/srv/archivr/work/.archivr"; }
@ -180,7 +181,8 @@ Only needed when binding to a non-loopback address:
```nix
services.archivr-server = {
bind = "0.0.0.0:8080";
listenAddress = "0.0.0.0";
port = 8080; # explicit, though 8080 is the default
openFirewall = true;
};
```

View file

@ -6,10 +6,13 @@
let
cfg = config.services.archivr-server;
# Derived bind string from separate address + port options.
bindStr = "${cfg.listenAddress}:${toString cfg.port}";
# Generate the TOML registry file from NixOS options.
# The auth DB is pinned to the StateDirectory so it survives upgrades.
configFile = pkgs.writeText "archivr-server.toml" ''
bind = "${cfg.bind}"
bind = "${bindStr}"
auth_db_path = "/var/lib/archivr-server/archivr-auth.sqlite"
${lib.concatMapStrings (a: ''
@ -32,17 +35,24 @@ in
description = "The archivr-server package to use.";
};
bind = lib.mkOption {
listenAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1:8080";
example = "0.0.0.0:8080";
default = "127.0.0.1";
example = "0.0.0.0";
description = ''
Address and port to listen on. Defaults to loopback only.
Binding to a non-loopback address exposes the server on the network;
put a reverse proxy with TLS and authentication in front before doing so.
IP address to listen on. Defaults to loopback only (127.0.0.1).
Set to {code}`0.0.0.0` (or a specific interface address) to expose
the server on the network. Always put a reverse proxy with TLS
in front before doing this.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "TCP port to listen on.";
};
archives = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
@ -59,7 +69,9 @@ in
path = lib.mkOption {
type = lib.types.str;
example = "/srv/archivr/personal/.archivr";
description = "Absolute path to the .archivr directory created by {command}`archivr init`.";
description = ''Absolute path to the .archivr directory created by {command}`archivr init`.
The parent directory (which also contains the {file}`store/` blob directory)
is whitelisted for read-write access under systemd hardening.'';
};
};
});
@ -92,9 +104,10 @@ in
type = lib.types.bool;
default = false;
description = ''
Open the firewall TCP port derived from
{option}`services.archivr-server.bind`. Only meaningful when binding
to a non-loopback address.
Open the firewall TCP port specified by
{option}`services.archivr-server.port`. Only meaningful when
{option}`services.archivr-server.listenAddress` is set to a
non-loopback address.
'';
};
};
@ -134,11 +147,14 @@ in
RestartSec = "5s";
# Hardening — make the entire FS read-only except for the state
# directory and the mounted archive directories.
# directory and the parent directory of each mounted archive.
# Each archive_path is an .archivr dir; its sibling store/ dir (where
# capture artifacts are written) lives at the same level. Whitelisting
# the parent covers both without over-permissioning.
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/archivr-server" ] ++ (map (a: a.path) cfg.archives);
ReadWritePaths = [ "/var/lib/archivr-server" ] ++ (map (a: builtins.dirOf a.path) cfg.archives);
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
LockPersonality = true;
RestrictNamespaces = true;
@ -148,9 +164,7 @@ in
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [
(lib.toInt (lib.last (lib.splitString ":" cfg.bind)))
];
allowedTCPPorts = [ cfg.port ];
};
};
}