Skip to content

Commit 40e46f6

Browse files
fix(trust): enforce §3 invariant 3 — BoJ ignores X-Trust-Level from non-loopback (standards#98) (#106)
## Summary Phase A contract §3 invariant 3 mandates: *"Any `X-Trust-Level` arriving from any other source MUST be ignored and treated as `untrusted`."* `BojRest.TrustPolicy.satisfies?/3` previously accepted `X-Trust-Level: authenticated|internal` from any caller — the third clause matched regardless of `is_local`. A non-loopback caller reaching BoJ's back-side bind (a §4 violation) could therefore claim any trust class by setting a header. This is the **BoJ-side half of the §3 defence-in-depth pair**; the gateway-side strip + re-emit landed as http-capability-gateway#11. ## The fix One new clause between the existing `:public` and `:authenticated` arms: ```elixir def satisfies?(_required, _trust, false), do: false ``` Plus the surrounding bookkeeping: - `trust_policy_test.exs` — 2 existing assertions inverted (they codified the buggy behaviour) + 2 new tests explicitly pinning §3 - `router_test.exs` — 2 existing tests inverted (used to assert that a non-loopback caller with `X-Trust-Level: authenticated|internal` passed; now assert they get 403) - `phase_c_seam_test.exs` — 5 `@tag skip:` annotations removed; module moduledoc updated (was a "documented finding" module; now a live enforcement test module) - `docs/integration/http-capability-gateway-boj-contract.md` — new "Implementation status (Phase C)" subsection naming the three controls (gateway-side / BoJ-side / operational §4) and where each landed - `lib/boj_rest/trust_policy.ex` — moduledoc updated with a "Phase A §3 invariant 3" subsection explaining the new clause ## Test plan - [x] `mix test test/{trust_policy,router,phase_c_seam}_test.exs` — **64/64 green** - [x] full `mix test` — 2 failures, both pre-existing baseline rot in `catalog_test.exs:85` + `catalog_properties_test.exs:73` (cartridge `auth.method` values), unrelated to this PR - [ ] reviewer: confirm §3 enforcement is correct — non-loopback caller cannot promote trust class via header - [ ] reviewer: confirm the inversion of the 2 router-tests + 2 trust_policy_tests is the right call (they were testing the buggy behaviour) ## Refs Refs hyperpolymath/standards#98 Refs hyperpolymath/standards#91 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 864434d commit 40e46f6

5 files changed

Lines changed: 71 additions & 44 deletions

File tree

docs/integration/http-capability-gateway-boj-contract.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,20 @@ router. This invariant is tested in Phase C (plan §Phase C, "Trust header
123123
forwarding security invariant") and hardened in Phase B (mTLS becomes the
124124
trust source so the resolved value cannot be header-forged at all).
125125

126+
**Implementation status (Phase C):**
127+
128+
- Gateway-side (1)+(2): `http-capability-gateway/lib/http_capability_gateway/proxy.ex`
129+
`build_backend_headers/1` strips client-supplied `X-Trust-Level` + `X-Request-ID`
130+
and re-emits the gateway-resolved values. Landed in http-capability-gateway#11.
131+
- BoJ-side (3): `boj_rest/lib/boj_rest/trust_policy.ex` `satisfies?/3` ignores
132+
the header for any non-loopback caller via the
133+
`satisfies?(_required, _trust, false), do: false` clause — defence in depth
134+
against (4) being violated by a misconfiguration. Verified by
135+
`elixir/test/phase_c_seam_test.exs` (9 tests, all live).
136+
- (4) is an operational/configuration invariant, not a code change; verified
137+
during Phase E rollout (production deployments MUST front BoJ with a
138+
loopback-only socket per the §Phase E runbook).
139+
126140
---
127141

128142
## 4. Headers BoJ MUST NOT forward to cartridges unchanged

elixir/lib/boj_rest/trust_policy.ex

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@ defmodule BojRest.TrustPolicy do
1414
Loopback callers (127.x.x.x, ::1) bypass trust enforcement — they are
1515
implicitly trusted as local processes (mcp-bridge, developer curl, etc.).
1616
17+
## Phase A §3 invariant 3 — non-loopback ignores `X-Trust-Level`
18+
19+
Per the http-capability-gateway BoJ contract §3 invariant 3, a
20+
non-loopback caller presenting an `X-Trust-Level` header has the
21+
header **ignored** — the caller is treated as if it presented no
22+
header at all. The gateway is the only source authorised to assert a
23+
trust class, and reaches BoJ on the loopback bind; any other source
24+
is by definition not the gateway. This is BoJ-side defence in depth;
25+
the gateway-side strip is the primary control. Without this clause an
26+
attacker who reaches BoJ's back-side bind directly (a §4 violation —
27+
back-side bind not network-isolated) could claim `internal` trust by
28+
setting a header.
29+
1730
## Exposure inference from auth.method
1831
1932
| auth.method | Required caller exposure |
@@ -49,10 +62,15 @@ defmodule BojRest.TrustPolicy do
4962
True when the given `X-Trust-Level` header value satisfies `required`.
5063
5164
Loopback callers (`is_local: true`) always satisfy any exposure level.
65+
Non-loopback callers (`is_local: false`) have their `X-Trust-Level`
66+
header **ignored** per Phase A §3 invariant 3 — they can therefore
67+
only satisfy `:public` exposure. The header alone cannot promote a
68+
non-loopback caller's trust class.
5269
"""
5370
@spec satisfies?(exposure(), trust_header(), boolean()) :: boolean()
5471
def satisfies?(_required, _trust, true), do: true
5572
def satisfies?(:public, _trust, _local), do: true
73+
def satisfies?(_required, _trust, false), do: false
5674
def satisfies?(:authenticated, trust, _local) when trust in ["authenticated", "internal"], do: true
5775
def satisfies?(_required, _trust, _local), do: false
5876
end

elixir/test/phase_c_seam_test.exs

Lines changed: 14 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,22 @@
11
# SPDX-License-Identifier: PMPL-1.0-or-later
22
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
33
#
4-
# Phase C seam tests — BoJ-side documentation of the http-capability-gateway
4+
# Phase C seam tests — BoJ-side enforcement of the http-capability-gateway
55
# contract §3 (trust-header security invariant).
66
#
7-
# *** Finding ***
8-
# Phase A contract §3 invariant 3 is currently **UNENFORCED** at the BoJ
9-
# side. `BojRest.TrustPolicy.satisfies?/3` accepts an `X-Trust-Level:
10-
# authenticated|internal` header from any caller — its third clause does
11-
# not branch on `is_local`. The contract states (boj-contract.md §3.3):
7+
# Companion to http-capability-gateway#11 (gateway-side X-Trust-Level
8+
# strip + re-emit) — together they form the §3 defence-in-depth pair:
129
#
13-
# BoJ's gnosis handler / BojRest.Router MUST treat X-Trust-Level as
14-
# authoritative only when the connection originates from the gateway
15-
# ... Any X-Trust-Level arriving from any other source MUST be ignored
16-
# and treated as untrusted.
10+
# gateway-side: strips client-supplied X-Trust-Level, re-emits from
11+
# the gateway's resolved trust decision (mTLS-derived
12+
# from Phase B onward)
13+
# BoJ-side: ignores X-Trust-Level from any non-loopback caller
14+
# (enforced by BojRest.TrustPolicy.satisfies?/3)
1715
#
18-
# Mitigation in practice today: §4 (back-side bind isolation) keeps the
19-
# non-loopback path unreachable in well-configured deployments. But the §3
20-
# enforcement is "mandatory, not advisory" per the contract, and is the
21-
# BoJ-side half of the defence-in-depth pair whose gateway-side half landed
22-
# in http-capability-gateway#11.
23-
#
24-
# This module:
25-
# * **Tests that currently pass** — positive controls (loopback HONOURS
26-
# the gateway-forwarded header; non-loopback to :public cartridge still
27-
# OK) and `is_local=true` function-level parity. Live now.
28-
# * **Tests that demonstrate the defect** — marked `@tag skip:` with a
29-
# reason. They will start passing as-is when the §3 fix lands in
30-
# `BojRest.TrustPolicy.satisfies?/3` (add clause: any non-loopback +
31-
# non-:public exposure → false).
16+
# All 9 tests here are live. The 4 non-loopback negative tests
17+
# previously documented an unfixed defect (with `@tag skip:`); the
18+
# defect was fixed by adding a `satisfies?(_, _, false), do: false`
19+
# clause and those tests are now enforced.
3220
#
3321
# Refs hyperpolymath/standards#98 (Phase C).
3422
# Refs hyperpolymath/standards#91 (HCG tier-2 channel).
@@ -42,11 +30,9 @@ defmodule BojRest.PhaseCSeamTest do
4230
@keyed_cart "airtable-mcp"
4331
@public_cart "boj-health"
4432

45-
@skip_finding "Phase A §3 invariant 3 unenforced in TrustPolicy.satisfies?/3 — see module doc + standards#98"
46-
4733
# ── §3 invariant 3 (positive control) — loopback HONOURS X-Trust-Level ────
4834

49-
describe "loopback callers (gateway-equivalent path) — live tests" do
35+
describe "loopback callers (gateway-equivalent path)" do
5036
test "X-Trust-Level: internal from 127.0.0.1 → allowed on :authenticated cartridge" do
5137
conn = invoke(@keyed_cart, "airtable_list_bases",
5238
remote_ip: {127, 0, 0, 1},
@@ -83,11 +69,8 @@ defmodule BojRest.PhaseCSeamTest do
8369
end
8470

8571
# ── §3 invariant 3 — non-loopback X-Trust-Level MUST be IGNORED ───────────
86-
# The 4 tests below DEMONSTRATE THE FINDING. They are skipped today; they
87-
# will pass unchanged once the §3 fix lands in TrustPolicy.satisfies?/3.
8872

89-
describe "non-loopback callers (header MUST be ignored per §3) — finding, skipped" do
90-
@tag skip: @skip_finding
73+
describe "non-loopback callers (header ignored per §3)" do
9174
test "X-Trust-Level: internal from 1.2.3.4 → 403 on :authenticated cartridge" do
9275
conn = invoke(@keyed_cart, "airtable_list_bases",
9376
remote_ip: {1, 2, 3, 4},
@@ -100,7 +83,6 @@ defmodule BojRest.PhaseCSeamTest do
10083
assert body(conn)["required"] == "authenticated"
10184
end
10285

103-
@tag skip: @skip_finding
10486
test "X-Trust-Level: authenticated from 1.2.3.4 → 403 on :authenticated cartridge" do
10587
conn = invoke(@keyed_cart, "airtable_list_bases",
10688
remote_ip: {1, 2, 3, 4},
@@ -111,7 +93,6 @@ defmodule BojRest.PhaseCSeamTest do
11193
assert body(conn)["error"] == "forbidden"
11294
end
11395

114-
@tag skip: @skip_finding
11596
test "X-Trust-Level: internal from IPv6 non-loopback → 403" do
11697
# IPv6 documentation prefix 2001:db8::/32 — never routable, never loopback.
11798
conn = invoke(@keyed_cart, "airtable_list_bases",
@@ -122,7 +103,6 @@ defmodule BojRest.PhaseCSeamTest do
122103
assert conn.status == 403
123104
end
124105

125-
@tag skip: @skip_finding
126106
test "SSE: X-Trust-Level: internal from 1.2.3.4 → 403 on :authenticated cartridge" do
127107
conn = invoke_sse(@keyed_cart, "airtable_list_bases",
128108
remote_ip: {1, 2, 3, 4},
@@ -143,7 +123,6 @@ defmodule BojRest.PhaseCSeamTest do
143123
end
144124
end
145125

146-
@tag skip: @skip_finding
147126
test "is_local=false rejects every non-:public exposure regardless of header" do
148127
for trust <- [nil, "public", "authenticated", "internal", "garbage"] do
149128
refute BojRest.TrustPolicy.satisfies?(:authenticated, trust, false),

elixir/test/router_test.exs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -401,30 +401,36 @@ defmodule BojRest.RouterTest do
401401
assert body["required"] == "authenticated"
402402
end
403403

404-
test "POST /invoke on keyed cartridge with X-Trust-Level: authenticated → allowed" do
404+
# Phase A §3 invariant 3: non-loopback X-Trust-Level is IGNORED. These two
405+
# used to assert the header was honoured for non-loopback callers; that was
406+
# the §3 hole the gateway was supposed to plug at the front and BoJ at the
407+
# back. BoJ-side enforcement now lives in TrustPolicy.satisfies?/3.
408+
test "POST /invoke on keyed cartridge with X-Trust-Level: authenticated from non-loopback → 403 (header ignored, §3)" do
405409
conn =
406410
conn(:post, "/cartridge/#{@keyed_cart}/invoke", Jason.encode!(%{tool: "airtable_list_bases"}))
407411
|> put_req_header("content-type", "application/json")
408412
|> put_req_header("x-trust-level", "authenticated")
409413
|> Map.put(:remote_ip, {1, 2, 3, 4})
410414
|> BojRest.Router.call(@opts)
411415

412-
assert conn.status in [200, 500]
416+
assert conn.status == 403
413417
body = Jason.decode!(conn.resp_body)
414-
refute body["error"] == "forbidden"
418+
assert body["error"] == "forbidden"
419+
assert body["detail"] == "insufficient-trust"
415420
end
416421

417-
test "POST /invoke on keyed cartridge with X-Trust-Level: internal → allowed" do
422+
test "POST /invoke on keyed cartridge with X-Trust-Level: internal from non-loopback → 403 (header ignored, §3)" do
418423
conn =
419424
conn(:post, "/cartridge/#{@keyed_cart}/invoke", Jason.encode!(%{tool: "airtable_list_bases"}))
420425
|> put_req_header("content-type", "application/json")
421426
|> put_req_header("x-trust-level", "internal")
422427
|> Map.put(:remote_ip, {1, 2, 3, 4})
423428
|> BojRest.Router.call(@opts)
424429

425-
assert conn.status in [200, 500]
430+
assert conn.status == 403
426431
body = Jason.decode!(conn.resp_body)
427-
refute body["error"] == "forbidden"
432+
assert body["error"] == "forbidden"
433+
assert body["detail"] == "insufficient-trust"
428434
end
429435

430436
test "POST /invoke on keyed cartridge with X-Trust-Level: public → 403 forbidden" do

elixir/test/trust_policy_test.exs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,24 @@ defmodule BojRest.TrustPolicyTest do
4949
assert TrustPolicy.satisfies?(:public, "internal", false)
5050
end
5151

52-
test "satisfies?: :authenticated requires authenticated or internal" do
53-
assert TrustPolicy.satisfies?(:authenticated, "authenticated", false)
54-
assert TrustPolicy.satisfies?(:authenticated, "internal", false)
52+
test "satisfies?: :authenticated requires authenticated or internal when loopback" do
53+
assert TrustPolicy.satisfies?(:authenticated, "authenticated", true)
54+
assert TrustPolicy.satisfies?(:authenticated, "internal", true)
5555
end
5656

5757
test "satisfies?: :authenticated rejects public or nil" do
5858
refute TrustPolicy.satisfies?(:authenticated, "public", false)
5959
refute TrustPolicy.satisfies?(:authenticated, nil, false)
6060
refute TrustPolicy.satisfies?(:authenticated, "garbage", false)
6161
end
62+
63+
# ── Phase A §3 invariant 3 — non-loopback `X-Trust-Level` is ignored ─────
64+
65+
test "satisfies?: :authenticated rejects non-loopback authenticated (header ignored, §3)" do
66+
refute TrustPolicy.satisfies?(:authenticated, "authenticated", false)
67+
end
68+
69+
test "satisfies?: :authenticated rejects non-loopback internal (header ignored, §3)" do
70+
refute TrustPolicy.satisfies?(:authenticated, "internal", false)
71+
end
6272
end

0 commit comments

Comments
 (0)