Skip to content

Commit 26544d8

Browse files
test(security): plaintext-listener forged-header regression + proxy to_existing_atom (audit #31, P5) (#36)
Two small defence-in-depth items from the priority-5 audit finding: 1. proxy.ex still used String.to_existing_atom/1 on conn.method. By the time we reach make_backend_request/4, Gateway.safe_verb/1 has already filtered for the seven supported methods — so to_existing_atom would not crash on real traffic. But the moduledoc comment in gateway.ex claims the gateway NEVER uses to_existing_atom on user input, which was half-true: this internal path did. Closed the gap with an explicit @method_atoms map so that grep'ing for `to_existing_atom` returns zero hits on any user-input path. 2. The existing strip-untrusted-headers describe block asserts only on `conn.assigns[:trust_level]`. Added an end-to-end regression net that asserts on the actual *response* the forged client receives against a route whose exposure is :internal: - forged "internal" from 10.0.0.99 -> 403 (NOT 200/502) - forged "authenticated" from 203.0.113.7 -> 403 - real "internal" from loopback -> 502 (i.e. allowed past policy to the down backend; CONTROL case so we know we're not just blocking everything) - garbage "ADMIN_OVERRIDE" header -> 403 Refs: #31 (self-audit, priority 5)
1 parent 78f4004 commit 26544d8

2 files changed

Lines changed: 140 additions & 1 deletion

File tree

lib/http_capability_gateway/proxy.ex

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,30 @@ defmodule HttpCapabilityGateway.Proxy do
183183
defp trust_to_string(level) when is_binary(level), do: level
184184
defp trust_to_string(_), do: "untrusted"
185185

186+
# Allowlist for HTTP method -> Req atom, mirroring the gateway's
187+
# @valid_methods allowlist (audit #31, P5 defence-in-depth).
188+
#
189+
# Previously this function called String.to_existing_atom/1 on conn.method.
190+
# By the time we reach here, Gateway.safe_verb/1 has already filtered for
191+
# the seven supported methods — so to_existing_atom would not crash on
192+
# real traffic. But the comment in gateway.ex claims the gateway NEVER
193+
# uses to_existing_atom on user input, which was half-true: this
194+
# internal path did. We close the gap with an explicit map lookup so
195+
# that grep'ing for `to_existing_atom` returns zero hits on any user
196+
# input path.
197+
@method_atoms %{
198+
"get" => :get,
199+
"post" => :post,
200+
"put" => :put,
201+
"delete" => :delete,
202+
"patch" => :patch,
203+
"head" => :head,
204+
"options" => :options
205+
}
206+
186207
# Make HTTP request to backend using Req
187208
defp make_backend_request(method, url, headers, body) do
188-
method_atom = method |> String.downcase() |> String.to_existing_atom()
209+
method_atom = Map.get(@method_atoms, String.downcase(method), :get)
189210

190211
options = [
191212
method: method_atom,

test/forged_trust_e2e_test.exs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
defmodule HttpCapabilityGateway.ForgedTrustE2ETest do
4+
@moduledoc """
5+
End-to-end regression net for the "plaintext listener + forged
6+
X-Trust-Level header" attack class (audit issue #31, priority 5).
7+
8+
The existing test/security_test.exs strip-untrusted-headers describe
9+
block asserts on `conn.assigns[:trust_level]` -- correct but partial.
10+
This file closes the loop by asserting on the actual *response* the
11+
forged client receives when it targets a route whose exposure is
12+
`:internal`. The result must be 403 (or stealth status), NEVER an
13+
upstream-proxied 200/502.
14+
"""
15+
16+
use ExUnit.Case, async: false
17+
import Plug.Conn
18+
import Plug.Test
19+
20+
alias HttpCapabilityGateway.{Gateway, PolicyCompiler}
21+
22+
setup_all do
23+
HttpCapabilityGateway.RateLimiter.init([])
24+
HttpCapabilityGateway.K9Contract.init()
25+
:ok
26+
end
27+
28+
setup do
29+
# A policy with an `internal`-exposure route. A correctly trusted
30+
# caller (loopback proxy passing the trust header) reaches the
31+
# backend; a forged-header caller from any other IP must be denied.
32+
policy = %{
33+
"dsl_version" => "1",
34+
"governance" => %{
35+
"global_verbs" => ["GET"],
36+
"routes" => [
37+
%{
38+
"path" => "/internal/secret",
39+
"verbs" => ["GET"],
40+
"exposure" => "internal",
41+
"backend" => "http://localhost:8080"
42+
}
43+
]
44+
}
45+
}
46+
47+
{:ok, table} = PolicyCompiler.compile(policy, delete_old: false)
48+
Application.put_env(:http_capability_gateway, :policy_table, table)
49+
Application.put_env(:http_capability_gateway, :stealth_profiles, %{})
50+
# Belt + braces: ensure strip is enabled and only loopback is trusted.
51+
Application.put_env(:http_capability_gateway, :strip_trust_header, true)
52+
Application.put_env(:http_capability_gateway, :trusted_proxies, ["127.0.0.1", "::1"])
53+
:ok
54+
end
55+
56+
describe "plaintext listener + forged X-Trust-Level against :internal route" do
57+
test "denies a forged 'internal' from a non-loopback IP" do
58+
# Forge the trust header AND pretend to be 10.x.x.x (i.e. NOT a
59+
# trusted upstream proxy). The strip plug must remove the header
60+
# before extract_trust runs; the gateway must then default to
61+
# :untrusted and deny on the :internal route.
62+
conn =
63+
conn(:get, "/internal/secret")
64+
|> put_req_header("x-trust-level", "internal")
65+
|> Map.put(:remote_ip, {10, 0, 0, 99})
66+
|> Gateway.call([])
67+
68+
# The response itself must be a denial, not a proxied 200/502.
69+
# Without the strip plug, this would be a 200 (or 502 because the
70+
# backend is down) — i.e. a successful privilege escalation.
71+
assert conn.status == 403, "forged trust must NOT reach :internal route"
72+
assert conn.halted
73+
74+
# Defence-in-depth: also assert on the resolved trust level.
75+
assert conn.assigns[:trust_level] == :untrusted
76+
end
77+
78+
test "denies forged 'authenticated' against :internal (rank insufficient)" do
79+
conn =
80+
conn(:get, "/internal/secret")
81+
|> put_req_header("x-trust-level", "authenticated")
82+
|> Map.put(:remote_ip, {203, 0, 113, 7})
83+
|> Gateway.call([])
84+
85+
assert conn.status == 403
86+
assert conn.assigns[:trust_level] == :untrusted
87+
end
88+
89+
test "preserves real internal trust from loopback (control case)" do
90+
# Loopback is in the trusted_proxies default; this case must NOT
91+
# be affected by the strip plug, so the gateway sees :internal
92+
# and allows the request to reach the (down) backend, yielding a
93+
# 502 Bad Gateway (NOT 403).
94+
conn =
95+
conn(:get, "/internal/secret")
96+
|> put_req_header("x-trust-level", "internal")
97+
# conn/3 from Plug.Test defaults remote_ip to {127, 0, 0, 1}
98+
|> Gateway.call([])
99+
100+
assert conn.assigns[:trust_level] == :internal
101+
# Backend is not running in tests, so the proxy returns 502.
102+
# Crucially, NOT 403 -- the policy decision allowed the request.
103+
assert conn.status in [200, 502]
104+
end
105+
106+
test "denies forged garbage trust value against :internal route" do
107+
# Even with a "trusted" remote IP, garbage values must parse to
108+
# :untrusted (SafeTrust.parse_trust fail-safe).
109+
conn =
110+
conn(:get, "/internal/secret")
111+
|> put_req_header("x-trust-level", "ADMIN_OVERRIDE")
112+
|> Gateway.call([])
113+
114+
assert conn.assigns[:trust_level] == :untrusted
115+
assert conn.status == 403
116+
end
117+
end
118+
end

0 commit comments

Comments
 (0)