Skip to content
Closed
Show file tree
Hide file tree
Changes from 29 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-trusted-proxy-findings.trigger
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
trigger=2026-09-02T09:06:00+09:00
reason=repair exact-head direct-router compatibility and malformed-forwarded fail-closed findings after research traceability
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
227 changes: 227 additions & 0 deletions .github/workflows/source-fix-151-trusted-proxy-findings.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
name: Source fix PR151 trusted-proxy findings

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

concurrency:
group: source-fix-pr151-trusted-proxy-findings
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: Apply exact-head production repair
shell: bash
run: |
set -euo pipefail
python - <<'PY'
from pathlib import Path

path = Path("src/lib.rs")
text = path.read_text(encoding="utf-8")

replacements = {
Comment thread
seonghobae marked this conversation as resolved.
Outdated
''' let peer_ip = match connect_info.as_ref().map(|peer| peer.0.ip()) {
Some(peer_ip) => peer_ip,
None => {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
"gateway requires peer address metadata; serve with `serve(...)` or `into_make_service_with_connect_info::<SocketAddr>()`",
);
}
};''': ''' let peer_ip = connect_info.as_ref().map(|peer| peer.0.ip());''',
''' let client_ip = client_ip_from_request(&headers, Some(peer_ip), &state.trusted_proxies);''': ''' let client_ip = client_ip_from_request(&headers, peer_ip, &state.trusted_proxies);''',
'''/// Build the HTTP application surface for management, DNSBL, and gateway
/// traffic. Gateway requests still require peer metadata via
/// `ConnectInfo<SocketAddr>`; use [`serve`] or
/// `Router::into_make_service_with_connect_info::<SocketAddr>()`.''': '''/// Build the HTTP application surface for management, DNSBL, and gateway
/// traffic. Direct router use without `ConnectInfo<SocketAddr>` remains
/// compatible: forwarded identity is ignored and the existing shared
/// unknown-client limiter bucket is used. [`serve`] preserves real peer
/// attribution for production TCP traffic.''',
''' match peer_ip {
Some(peer_ip) if trusted_proxies.iter().any(|proxy| proxy.contains(peer_ip)) => {
trusted_forwarded_chain_client_ip(x_forwarded_for, trusted_proxies)
.or_else(|| trusted_real_ip_value(x_real_ip))
.or(Some(peer_ip))
}
Some(peer_ip) => Some(peer_ip),
None => None,
}''': ''' match peer_ip {
Some(peer_ip) if trusted_proxies.iter().any(|proxy| proxy.contains(peer_ip)) => {
match trusted_forwarded_chain_client_ip_checked(
x_forwarded_for,
trusted_proxies,
) {
Ok(Some(client_ip)) => Some(client_ip),
Ok(None) => trusted_real_ip_value(x_real_ip).or(Some(peer_ip)),
Err(()) => Some(peer_ip),
}
}
Some(peer_ip) => Some(peer_ip),
None => None,
}''',
'''pub fn trusted_forwarded_chain_client_ip(
x_forwarded_for: Option<&str>,
trusted_proxies: &[IpNet],
) -> Option<IpAddr> {
let forwarded = x_forwarded_for?;
let mut client_ip = None;
for candidate in forwarded.split(',').rev() {
let candidate = candidate.trim();
if candidate.is_empty() {
continue;
}
let ip = match candidate.parse::<IpAddr>() {
Ok(ip) => ip,
Err(_) => continue,
};
if trusted_proxies.iter().any(|proxy| proxy.contains(ip)) {
continue;
}
client_ip = Some(ip);
break;
}
client_ip
}''': '''pub fn trusted_forwarded_chain_client_ip(
x_forwarded_for: Option<&str>,
trusted_proxies: &[IpNet],
) -> Option<IpAddr> {
trusted_forwarded_chain_client_ip_checked(x_forwarded_for, trusted_proxies)
.ok()
.flatten()
}

/// Parse the complete forwarded chain before selecting an identity. Any
/// empty or unparsable hop invalidates the entire chain so callers can
/// fall back to the direct peer without consulting `X-Real-IP`.
fn trusted_forwarded_chain_client_ip_checked(
x_forwarded_for: Option<&str>,
trusted_proxies: &[IpNet],
) -> Result<Option<IpAddr>, ()> {
let Some(forwarded) = x_forwarded_for else {
return Ok(None);
};
let hops = forwarded
.split(',')
.map(|candidate| {
let candidate = candidate.trim();
if candidate.is_empty() {
return Err(());
}
candidate.parse::<IpAddr>().map_err(|_| ())
})
.collect::<Result<Vec<_>, _>>()?;
Ok(hops
.into_iter()
.rev()
.find(|ip| !trusted_proxies.iter().any(|proxy| proxy.contains(*ip))))
}''',
''' #[tokio::test]
async fn gateway_rejects_missing_connect_info_instead_of_silently_sharing_unknown_ip() {
let app = build_app(AppState::seeded(None).with_rate_limit(1, 60));

let response = app_request(
&app,
Request::builder()
.method(Method::GET)
.uri("/gateway/demo")
.body(Body::empty())
.unwrap(),
)
.await;

assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(
body_text(response)
.await
.contains("gateway requires peer address metadata")
);
}''': ''' #[tokio::test]
async fn gateway_missing_connect_info_uses_unknown_bucket_without_trusting_headers() {
let app = build_app(AppState::seeded(None).with_rate_limit(1, 60));

let first = app_request(
&app,
Request::builder()
.method(Method::GET)
.uri("/gateway/demo")
.header("x-forwarded-for", "203.0.113.9")
.header("x-real-ip", "203.0.113.10")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(first.status(), StatusCode::OK);

let second = app_request(
&app,
Request::builder()
.method(Method::GET)
.uri("/gateway/demo")
.header("x-forwarded-for", "198.51.100.7")
.header("x-real-ip", "198.51.100.8")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
}''',
}

for old, new in replacements.items():
count = text.count(old)
if count != 1:
raise SystemExit(f"expected exactly one repair target, found {count}: {old[:80]!r}")
text = text.replace(old, new)

path.write_text(text, encoding="utf-8")
PY
cargo fmt --all
git diff --check
- name: Verify exact regressions
shell: bash
run: |
set -euo pipefail
cargo test --test trusted_forwarded_fail_closed
cargo test --test trusted_proxy_admission_regressions
cargo test gateway_missing_connect_info_uses_unknown_bucket_without_trusting_headers
cargo test trusted_forwarded_chain_client_ip
- 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-trusted-proxy-findings.yml \
.github/source-fix-151-trusted-proxy-findings.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 'fix(gateway): fail closed on forwarded identity races'
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
Loading
Loading