Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
d0914ae
feat(gateway): trust forwarded IPs only from trusted proxies
codex Aug 30, 2026
6fd62a7
fix(gateway): harden trusted proxy attribution
codex Aug 30, 2026
6cfec21
test(gateway): fuzz trusted forwarded client attribution
codex Aug 30, 2026
b009e87
fix(gateway): accept mapped trusted proxy peers
codex Aug 30, 2026
b92c55b
test(gateway): align mapped proxy trust invariants
codex Aug 30, 2026
66dbfd0
docs(gateway): clarify trusted proxy runtime contract
codex Aug 30, 2026
82caad0
fix(gateway): reject invalid mapped proxy cidrs
codex Aug 30, 2026
bcbb901
Merge origin/main into codex/trusted-proxy-admission
codex Aug 30, 2026
4a3d452
Merge origin/main into codex/trusted-proxy-admission
codex Aug 31, 2026
597e723
fix(gateway): keep admin credential provenance accurate
seonghobae Sep 1, 2026
629afc0
test(gateway): run trusted-proxy fuzz target in CI
seonghobae Sep 1, 2026
a183581
style: apply rustfmt to credential regression
seonghobae Sep 1, 2026
97d9c4b
merge: synchronize trusted-proxy admission with protected main
seonghobae Sep 1, 2026
72ac1a2
chore(stack): integrate pinned hosted-runner prerequisite
seonghobae Sep 1, 2026
84c5fe3
chore(stack): converge trusted-proxy slice on current runner prerequi…
seonghobae Sep 1, 2026
4f7bda1
test(fuzz): seed trusted-forwarding parser corpus
seonghobae Sep 1, 2026
9935392
test(fuzz): seed IPv6 forwarding chain
seonghobae Sep 1, 2026
c6b34d7
test(fuzz): seed IPv4-mapped forwarding chain
seonghobae Sep 1, 2026
0d05448
test(fuzz): seed malformed forwarding chain
seonghobae Sep 1, 2026
00e9857
merge: converge trusted-proxy stack on current runner prerequisite
seonghobae Sep 1, 2026
c443c80
test(gateway): fail closed on malformed forwarded chains
seonghobae Sep 1, 2026
a975edd
test(gateway): make fuzz oracle reject malformed forwarding
seonghobae Sep 1, 2026
2ce8384
test(gateway): fail closed in trusted-forwarding property model
seonghobae Sep 1, 2026
cd39417
test(gateway): reproduce trusted-proxy attribution bypasses
seonghobae Sep 1, 2026
50721e5
ci: add exact-head PR151 source repair
seonghobae Sep 1, 2026
eb3a015
ci: trigger exact-head PR151 trusted-proxy repair
seonghobae Sep 1, 2026
d40b374
chore(ci): trigger trusted-proxy causal repair
seonghobae Sep 2, 2026
02acb9e
docs(security): ground trusted-proxy policy in primary research
seonghobae Sep 2, 2026
01bc90b
chore(ci): retrigger trusted-proxy causal repair
seonghobae Sep 2, 2026
a30d7c6
fix(gateway): fail closed on forwarded identity races
opencode-agent[bot] Sep 2, 2026
99ccb76
chore(ci): add exact malformed-chain contract repair
seonghobae Sep 2, 2026
5e90cdd
chore(ci): trigger malformed-chain contract repair
seonghobae Sep 2, 2026
4d7425f
chore(ci): remove unregistered source-fix trigger
seonghobae Sep 2, 2026
cdd2d77
chore(ci): remove unregistered source-fix workflow
seonghobae Sep 2, 2026
87a8d49
chore(ci): stage malformed-chain unit repair v2
seonghobae Sep 2, 2026
f911954
chore(ci): trigger malformed-chain unit repair v2
seonghobae Sep 2, 2026
f8f8a82
test(gateway): fail closed on malformed forwarded chain
opencode-agent[bot] Sep 2, 2026
65a2b7f
docs(security): specify malformed forwarded-chain fallback
seonghobae Sep 2, 2026
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
1 change: 1 addition & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
34 changes: 34 additions & 0 deletions docs/runbooks/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,40 @@ 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 parses the complete
`X-Forwarded-For` chain before selecting client identity. A syntactically valid
chain is scanned from right to left and the first address that is not itself a
trusted proxy becomes the client identity. If the header is absent, or a valid
chain contains no untrusted hop, Wardnet may use `X-Real-IP` from that same
trusted-proxy context and otherwise uses the direct peer. If **any**
`X-Forwarded-For` hop is empty or unparsable, the whole chain is invalid:
Wardnet falls back directly to the peer and does **not** consult `X-Real-IP`.
Trusted ingress proxies should normalize inbound forwarding headers before
appending their own hop. If no trusted proxy range is configured, forwarded
headers are ignored entirely.

Operational and research grounding:

- 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.
- Pletinckx, S., Kruegel, C., & Vigna, G. (2025). *A large-scale measurement study of the PROXY protocol and its security implications*. Network and Distributed System Security Symposium (NDSS 2025). https://doi.org/10.14722/ndss.2025.242247
Their Internet-scale study shows that accepting proxy-supplied client identity from untrusted sources can bypass proxy security controls and backend access restrictions. Wardnet applies the same trust-boundary principle to HTTP forwarding metadata: only explicitly trusted peer networks may supply client identity used for security decisions.

The NDSS paper is linked rather than vendored under `docs/papers/`: the paper is publicly readable, but this repository has not established redistribution rights for committing a copy. This follows the repository rule to attach a PDF only when redistribution is clearly permitted.

## 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

# Empty table => this crate is its own workspace root, isolated from the
# repository's primary workspace. Do not remove.
[workspace]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
198.51.100.77, ::ffff:192.0.2.10
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
198.51.100.77, bad-ip, 192.0.2.10
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
198.51.100.77, 203.0.113.9, 192.0.2.10
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2001:db8:ffff::1, 2001:db8::10
163 changes: 163 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,163 @@
#![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. Trusted peers may
//! supply an `X-Forwarded-For` identity only when every hop parses; malformed
//! chains fail closed to the direct peer.

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 {
let parsed = forwarded
.split(',')
.map(|hop| {
let hop = hop.trim();
if hop.is_empty() {
return Err(());
}
hop.parse::<IpAddr>().map_err(|_| ())
})
.collect::<Result<Vec<_>, _>>();
let Ok(hops) = parsed else {
return Some(peer_ip);
};
if let Some(client_ip) = hops
.into_iter()
.rev()
.find(|ip| !is_trusted_single_host(*ip, trusted_proxy_ip))
{
return Some(client_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 fail closed on malformed forwarding metadata"
);
});
Loading
Loading