Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ permissions:

jobs:
rust:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ concurrency:

jobs:
fuzz:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
Expand All @@ -32,6 +32,7 @@ jobs:
- fuzz_appdata_json
- fuzz_parse_admin_tokens
- fuzz_dnsbl_zone
- fuzz_trusted_forwarded_client_ip
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/scorecard-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ permissions:
jobs:
analysis:
name: Scorecard Analysis
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write
Expand Down
26 changes: 26 additions & 0 deletions docs/runbooks/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,32 @@ cargo run
Health reports `credentials_source` (`file` / `env` / `none`) and
`admin_auth_configured` (boolean) without exposing secret values.

### Trusted proxy client IP attribution

Wardnet now treats forwarded client IP headers as untrusted by default. Gateway
rate limiting, DNSBL matching, and event attribution use the direct peer
address unless that peer matches `TRUSTED_PROXY_CIDRS`.

```bash
TRUSTED_PROXY_CIDRS=192.0.2.0/24,2001:db8::/32 \
cargo run
```

When a peer is in that allowlist, Wardnet honors the first `X-Forwarded-For`
chain element that is not itself another trusted proxy, scanning the chain from
right to left, and falls back to `X-Real-IP` only from that trusted proxy
context. Trusted ingress proxies must normalize inbound forwarding headers
before appending their own hop so attacker-supplied leading values cannot
survive unchanged. If no trusted proxy range is configured, spoofed forwarded
headers are ignored.

Operational references:

- Petersson, A., & Nilsson, M. (2014). *Forwarded HTTP Extension* (RFC 7239). IETF. https://www.rfc-editor.org/info/rfc7239
This standard defines proxy-disclosed client/address chain metadata and warns that forwarded headers cannot be assumed correct without trusted intermediary policy.
- MDN contributors. (2025, July 4). *Forwarded header*. MDN Web Docs. https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Forwarded
MDN documents the comma-appended proxy chain model and the de facto relationship between `Forwarded` and `X-Forwarded-For`, which is the operational shape Wardnet validates here.

## Health Check

```bash
Expand Down
7 changes: 7 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ test = false
doc = false
bench = false

[[bin]]
name = "fuzz_trusted_forwarded_client_ip"
path = "fuzz_targets/fuzz_trusted_forwarded_client_ip.rs"
test = false
doc = false
bench = false
Comment on lines +56 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 New fuzz target lacks documentation

The fuzzing target table omits fuzz_trusted_forwarded_client_ip and its invariant. Repository guidance says this document lists each target's invariants.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


# Empty table => this crate is its own workspace root, isolated from the
# repository's primary workspace. Do not remove.
[workspace]
150 changes: 150 additions & 0 deletions fuzz/fuzz_targets/fuzz_trusted_forwarded_client_ip.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#![no_main]
//! Fuzz trusted client-IP attribution for forwarded proxy headers.
//!
//! `effective_client_ip` is a trust-boundary parser: it decides whether
//! attacker-controlled forwarding headers can influence rate limiting, DNSBL
//! checks, and audit/event attribution. Arbitrary chains, invalid hops, IPv4,
//! IPv6, trusted peers, and untrusted peers must never panic, and the trusted
//! peer path must match the documented right-to-left selection rule.

use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use waf_ids_ai_soc::{IpNet, effective_client_ip};

#[derive(Arbitrary, Debug, Clone)]
enum AnyIp {
V4(u32),
V6(u128),
}

impl AnyIp {
fn into_ip(self) -> IpAddr {
match self {
Self::V4(raw) => IpAddr::V4(Ipv4Addr::from(raw)),
Self::V6(raw) => IpAddr::V6(Ipv6Addr::from(raw)),
}
}
}

fn normalized_ip(addr: IpAddr) -> IpAddr {
match addr {
IpAddr::V6(ip) => ip
.to_ipv4_mapped()
.map(IpAddr::V4)
.unwrap_or(IpAddr::V6(ip)),
IpAddr::V4(ip) => IpAddr::V4(ip),
}
}

fn is_trusted_single_host(ip: IpAddr, trusted_proxy_ip: IpAddr) -> bool {
normalized_ip(ip) == normalized_ip(trusted_proxy_ip)
}

#[derive(Arbitrary, Debug)]
enum Hop {
Ip(AnyIp),
Invalid(String),
Empty,
}

impl Hop {
fn into_text(self) -> String {
match self {
Self::Ip(ip) => ip.into_ip().to_string(),
Self::Invalid(raw) => raw,
Self::Empty => " ".to_string(),
}
}
}

#[derive(Arbitrary, Debug)]
struct Input {
trusted_proxy: AnyIp,
peer_ip: Option<AnyIp>,
trust_peer: bool,
forwarded_hops: Vec<Hop>,
x_real_ip: Option<Hop>,
}

fn expected_client_ip(
peer_ip: Option<IpAddr>,
x_forwarded_for: Option<&str>,
x_real_ip: Option<&str>,
trusted_proxy_ip: IpAddr,
trust_peer: bool,
) -> Option<IpAddr> {
let peer_ip = match (peer_ip, trust_peer) {
(Some(_), false) => peer_ip,
(Some(peer_ip), true) => Some(peer_ip),
(None, _) => return None,
}?;

if !trust_peer {
return Some(peer_ip);
}

if let Some(forwarded) = x_forwarded_for {
for hop in forwarded.split(',').rev() {
let hop = hop.trim();
if hop.is_empty() {
continue;
}
let Ok(ip) = hop.parse::<IpAddr>() else {
continue;
};
if is_trusted_single_host(ip, trusted_proxy_ip) {
continue;
}
return Some(ip);
}
}

x_real_ip
.and_then(|value| value.trim().parse::<IpAddr>().ok())
.or(Some(peer_ip))
}

fuzz_target!(|input: Input| {
let trusted_proxy_ip = input.trusted_proxy.clone().into_ip();
let peer_ip = input.peer_ip.map(AnyIp::into_ip);
let trusted_cidr = match trusted_proxy_ip {
IpAddr::V4(ip) => format!("{ip}/32"),
IpAddr::V6(ip) => format!("{ip}/128"),
};
let trusted_proxy = IpNet::parse(&trusted_cidr).expect("single-host CIDR must parse");
let trusted_proxies = vec![trusted_proxy.clone()];
let peer_ip = if input.trust_peer && peer_ip.is_some() {
Some(trusted_proxy_ip)
} else {
peer_ip
};
let trust_peer = peer_ip
.map(|peer_ip| is_trusted_single_host(peer_ip, trusted_proxy_ip))
.unwrap_or(false);
let x_forwarded_for = if input.forwarded_hops.is_empty() {
None
} else {
Some(
input
.forwarded_hops
.into_iter()
.map(Hop::into_text)
.collect::<Vec<_>>()
.join(","),
)
};
let x_real_ip = input.x_real_ip.map(Hop::into_text);
let resolved = effective_client_ip(peer_ip, x_forwarded_for.as_deref(), x_real_ip.as_deref(), &trusted_proxies);
let expected = expected_client_ip(
peer_ip,
x_forwarded_for.as_deref(),
x_real_ip.as_deref(),
trusted_proxy_ip,
trust_peer,
);
assert_eq!(
resolved, expected,
"trusted client attribution must match the right-to-left trust model"
);
});
Loading
Loading