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:
parent
7ed7cb882f
commit
71e176cbd1
4 changed files with 70 additions and 27 deletions
|
|
@ -63,6 +63,6 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
println!("archivr-server listening on http://{addr}");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, VecDeque},
|
collections::{HashMap, VecDeque},
|
||||||
net::IpAddr,
|
net::{IpAddr, SocketAddr},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
|
|
@ -31,7 +31,7 @@ use parking_lot::Mutex;
|
||||||
use archivr_core::{archive, capture, database};
|
use archivr_core::{archive, capture, database};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Path, Query, Request, State},
|
extract::{ConnectInfo, Path, Query, Request, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
|
|
@ -165,12 +165,39 @@ async fn login_rate_limit(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_client_ip(req: &Request) -> IpAddr {
|
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")
|
.get("x-forwarded-for")
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|s| s.split(',').next())
|
.and_then(|s| s.split(',').next())
|
||||||
.and_then(|s| s.trim().parse().ok())
|
.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`.
|
/// Build the Axum router from a pre-constructed `AppState`.
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,8 @@ enable the service:
|
||||||
{
|
{
|
||||||
services.archivr-server = {
|
services.archivr-server = {
|
||||||
enable = true;
|
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 = [
|
archives = [
|
||||||
{ id = "personal"; label = "Personal"; path = "/srv/archivr/personal/.archivr"; }
|
{ id = "personal"; label = "Personal"; path = "/srv/archivr/personal/.archivr"; }
|
||||||
{ id = "work"; label = "Work"; path = "/srv/archivr/work/.archivr"; }
|
{ id = "work"; label = "Work"; path = "/srv/archivr/work/.archivr"; }
|
||||||
|
|
@ -180,7 +181,8 @@ Only needed when binding to a non-loopback address:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
services.archivr-server = {
|
services.archivr-server = {
|
||||||
bind = "0.0.0.0:8080";
|
listenAddress = "0.0.0.0";
|
||||||
|
port = 8080; # explicit, though 8080 is the default
|
||||||
openFirewall = true;
|
openFirewall = true;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,13 @@
|
||||||
let
|
let
|
||||||
cfg = config.services.archivr-server;
|
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.
|
# Generate the TOML registry file from NixOS options.
|
||||||
# The auth DB is pinned to the StateDirectory so it survives upgrades.
|
# The auth DB is pinned to the StateDirectory so it survives upgrades.
|
||||||
configFile = pkgs.writeText "archivr-server.toml" ''
|
configFile = pkgs.writeText "archivr-server.toml" ''
|
||||||
bind = "${cfg.bind}"
|
bind = "${bindStr}"
|
||||||
auth_db_path = "/var/lib/archivr-server/archivr-auth.sqlite"
|
auth_db_path = "/var/lib/archivr-server/archivr-auth.sqlite"
|
||||||
|
|
||||||
${lib.concatMapStrings (a: ''
|
${lib.concatMapStrings (a: ''
|
||||||
|
|
@ -32,17 +35,24 @@ in
|
||||||
description = "The archivr-server package to use.";
|
description = "The archivr-server package to use.";
|
||||||
};
|
};
|
||||||
|
|
||||||
bind = lib.mkOption {
|
listenAddress = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "127.0.0.1:8080";
|
default = "127.0.0.1";
|
||||||
example = "0.0.0.0:8080";
|
example = "0.0.0.0";
|
||||||
description = ''
|
description = ''
|
||||||
Address and port to listen on. Defaults to loopback only.
|
IP address to listen on. Defaults to loopback only (127.0.0.1).
|
||||||
Binding to a non-loopback address exposes the server on the network;
|
Set to {code}`0.0.0.0` (or a specific interface address) to expose
|
||||||
put a reverse proxy with TLS and authentication in front before doing so.
|
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 {
|
archives = lib.mkOption {
|
||||||
type = lib.types.listOf (lib.types.submodule {
|
type = lib.types.listOf (lib.types.submodule {
|
||||||
options = {
|
options = {
|
||||||
|
|
@ -59,7 +69,9 @@ in
|
||||||
path = lib.mkOption {
|
path = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
example = "/srv/archivr/personal/.archivr";
|
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;
|
type = lib.types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
description = ''
|
description = ''
|
||||||
Open the firewall TCP port derived from
|
Open the firewall TCP port specified by
|
||||||
{option}`services.archivr-server.bind`. Only meaningful when binding
|
{option}`services.archivr-server.port`. Only meaningful when
|
||||||
to a non-loopback address.
|
{option}`services.archivr-server.listenAddress` is set to a
|
||||||
|
non-loopback address.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -134,11 +147,14 @@ in
|
||||||
RestartSec = "5s";
|
RestartSec = "5s";
|
||||||
|
|
||||||
# Hardening — make the entire FS read-only except for the state
|
# 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;
|
NoNewPrivileges = true;
|
||||||
PrivateTmp = true;
|
PrivateTmp = true;
|
||||||
ProtectSystem = "strict";
|
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" ];
|
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
|
||||||
LockPersonality = true;
|
LockPersonality = true;
|
||||||
RestrictNamespaces = true;
|
RestrictNamespaces = true;
|
||||||
|
|
@ -148,9 +164,7 @@ in
|
||||||
};
|
};
|
||||||
|
|
||||||
networking.firewall = lib.mkIf cfg.openFirewall {
|
networking.firewall = lib.mkIf cfg.openFirewall {
|
||||||
allowedTCPPorts = [
|
allowedTCPPorts = [ cfg.port ];
|
||||||
(lib.toInt (lib.last (lib.splitString ":" cfg.bind)))
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue