Skip to content
Closed
Show file tree
Hide file tree
Changes from 32 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
4 changes: 4 additions & 0 deletions .github/source-fix-151-forwarded-unit-contract.trigger
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
trigger=2026-09-02T09:15:00+09:00
reason=align stale in-crate malformed-forwarding regression with fail-closed production contract
runner=ubuntu-slim
head_guard=feat/trusted-proxy-admission-v3
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
74 changes: 74 additions & 0 deletions .github/workflows/source-fix-151-forwarded-unit-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Source fix PR151 forwarded unit contract

on:
push:
branches:
- feat/trusted-proxy-admission-v3
paths:
- .github/source-fix-151-forwarded-unit-contract.trigger

concurrency:
group: source-fix-pr151-forwarded-unit-contract
cancel-in-progress: true

jobs:
repair:
permissions:
contents: write
runs-on: ubuntu-slim
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7
with:
ref: feat/trusted-proxy-admission-v3
fetch-depth: 0
persist-credentials: false
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30
with:
toolchain: stable
components: rustfmt
- name: Align in-crate malformed-chain regression with fail-closed policy
shell: bash
run: |
set -euo pipefail
python - <<'PY'
from pathlib import Path

path = Path("src/lib.rs")
text = path.read_text(encoding="utf-8")
old = ''' #[test]\n fn client_ip_from_request_skips_invalid_spoofed_leading_forwarded_hops() {\n let mut headers = HeaderMap::new();\n headers.insert(\n "x-forwarded-for",\n HeaderValue::from_static("not-an-ip, 203.0.113.9"),\n );\n assert_eq!(\n client_ip_from_request(\n &headers,\n Some("192.0.2.44".parse().unwrap()),\n &[IpNet::parse("192.0.2.0/24").unwrap()],\n ),\n Some("203.0.113.9".parse().unwrap())\n );\n }'''
new = ''' #[test]\n fn client_ip_from_request_rejects_invalid_forwarded_hops() {\n let mut headers = HeaderMap::new();\n headers.insert(\n "x-forwarded-for",\n HeaderValue::from_static("not-an-ip, 203.0.113.9"),\n );\n headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.88"));\n assert_eq!(\n client_ip_from_request(\n &headers,\n Some("192.0.2.44".parse().unwrap()),\n &[IpNet::parse("192.0.2.0/24").unwrap()],\n ),\n Some("192.0.2.44".parse().unwrap()),\n "a malformed X-Forwarded-For chain must fall back to the direct peer and must not consult X-Real-IP",\n );\n }'''
if text.count(old) != 1:
raise SystemExit(f"expected one stale unit-test contract, found {text.count(old)}")
path.write_text(text.replace(old, new), encoding="utf-8")
PY
cargo fmt --all
git diff --check
- name: Verify focused malformed-chain regressions
shell: bash
run: |
set -euo pipefail
cargo test client_ip_from_request_rejects_invalid_forwarded_hops
cargo test --test trusted_forwarded_fail_closed
cargo test --test trusted_proxy_admission_regressions
- name: Self-remove and publish non-force repair
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
rm -f \
.github/workflows/source-fix-151-forwarded-unit-contract.yml \
.github/source-fix-151-forwarded-unit-contract.trigger
git add -A
git diff --cached --check
git config user.name 'opencode-agent[bot]'
git config user.email '219766164+opencode-agent[bot]@users.noreply.github.com'
git commit -m 'test(gateway): align malformed forwarding contract'
git fetch --no-tags origin feat/trusted-proxy-admission-v3
remote_head="$(git rev-parse FETCH_HEAD)"
if ! git merge-base --is-ancestor "$remote_head" HEAD; then
echo "writer branch moved concurrently; refusing to overwrite" >&2
exit 1
fi
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git push origin HEAD:feat/trusted-proxy-admission-v3
30 changes: 30 additions & 0 deletions docs/runbooks/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,36 @@ 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 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