Skip to content

Commit 07d671c

Browse files
fix(boj): bind Cowboy to 127.0.0.1 by default (HCG tier-2 E1 prereq) (#130)
## Summary Code-enforces the ADR-0004 §1 invariant that BoJ's back-side bind is not externally routable in deployments fronted by `http-capability-gateway` (HCG tier-2). Previously, `Plug.Cowboy` started with `port: 7700` and **no `ip:` option**, which defaults to `0.0.0.0` — all interfaces. The "loopback-only" property in the contract document (`docs/integration/http-capability-gateway-boj-contract.md` §1) and the Phase E rollout-runbook §1.4 prereq #6 (boj-server#128) were therefore **operational assertions**, not code-enforced. This is **action item #6** from the Phase E consumer-side audit posted on [`standards#100`](hyperpolymath/standards#100 (comment)) — pre-staged ahead of E1/E2 wiring so it ships independent of Phase D's still-scaffolded benchmark gate. `Refs hyperpolymath/standards#100` (NOT Closes — joint-close is owner-only). `Refs hyperpolymath/standards#91`. ## What's in | File | Change | |---|---| | `elixir/lib/boj_rest/application.ex` | Read `bind_ip` config; parse via `:inet.parse_address/1`; pass as `ip:` to `Plug.Cowboy.options`. New `parse_bind_ip/1` public helper. Moduledoc updated to document the new bind-default. | | `elixir/config/config.exs` | `bind_ip: System.get_env("BOJ_BIND_IP") || "127.0.0.1"` | | `elixir/test/application_test.exs` | New file — 7 tests on `parse_bind_ip/1` (IPv4 loopback, IPv4 all-interfaces, IPv6 loopback, IPv6 all-interfaces, invalid, empty, error-message-mentions-input). | | `CHANGELOG.md` | New `### Changed` entry under `[Unreleased]`. | ## Behaviour - **Default**: bind `127.0.0.1` (IPv4 loopback only). - **Override**: set `BOJ_BIND_IP` to any IPv4/IPv6 address string parseable by `:inet.parse_address/1`. Use `0.0.0.0` or `::` to opt back into all-interfaces for legacy/standalone deployments. - **Invalid `BOJ_BIND_IP`**: fails fast at boot with a descriptive `ArgumentError` naming the offending value. Preferred to silently degrading to `0.0.0.0` and exposing the back-side bind. ## Verification ``` $ mix test test/application_test.exs Finished in 0.02 seconds (0.02s async, 0.00s sync) 7 tests, 0 failures ``` Full `mix test` shows the same 2 baseline-rot failures present on `origin/main` (`whisper-mcp` cartridge has `auth.method: "optional_api_key"`, which the catalog property test doesn't accept). Verified by running `mix test` against `origin/main` HEAD `92f3b3a1` in a clean detached worktree: | Branch | properties | tests | failures | |---|---|---|---| | `origin/main` (`92f3b3a1`) | 10 | 188 | 2 | | this branch | 10 | 195 | 2 | The two failures are *the same* failures; my +7 tests all pass. **Not introduced by this PR** — out of scope. (Per `[[feedback_pr_triage_crosscheck_main]]` from session memory: red checks that are also red on main are baseline rot, not PR defects.) ## Test plan - [x] `mix test test/application_test.exs` — 7/7 pass locally. - [x] Cross-check against `origin/main` — failures match (baseline rot, not regression). - [ ] CI green — governance / hypatia / a2ml / k9 / dogfooding all pass. - [ ] Manual sanity: starting BoJ in a dev shell with the default config binds on `127.0.0.1:7700`, and `curl http://127.0.0.1:7700/health` succeeds while a connect from a non-loopback IP fails at the socket layer (owner-verified pre-merge in any environment where this is reachable). ## Risk **Low.** The change is one Plug.Cowboy option and a small parser with explicit fail-fast. The only behaviour difference is that a dev / packaged deployment that *relied* on the implicit `0.0.0.0` default now needs to opt in via `BOJ_BIND_IP`. Two known sites currently assume all-interfaces and may need follow-up: - `stapeln.toml` line 100: `APP_HOST = "[::]"` — used by the stapeln packager. Audit item #7; out of scope for this PR (filing as a follow-up if desired). - `k8s/deployment.yaml` / `k8s/service.yaml`: containerPort 7700 exposed via Service. Audit item #8; out of scope here, and this PR makes that exposure safer (the BoJ process itself no longer binds all-interfaces), but the Service should still be tightened to ClusterIP-only as part of E1/E2. If the owner wants those two folded in, happy to extend this PR or to file as follow-ups. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a3ae35f commit 07d671c

4 files changed

Lines changed: 92 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ All notable changes to Bundle of Joy Server are documented here.
2929

3030
### Changed
3131

32+
- **Cowboy listener now binds to `127.0.0.1` by default** (was: all
33+
interfaces). Configurable via the `BOJ_BIND_IP` environment variable;
34+
invalid values fail-fast at boot rather than silently falling back to
35+
`0.0.0.0`. This is the code-enforced expression of the ADR-0004 §1
36+
invariant that BoJ's back-side bind is not externally routable in
37+
deployments fronted by `http-capability-gateway` (HCG tier-2). Phase E
38+
rollout-runbook §1.4 prerequisite #6. Legacy/standalone deployments
39+
that want all-interfaces exposure must now opt in explicitly
40+
(`BOJ_BIND_IP=0.0.0.0` or `BOJ_BIND_IP=::`). Refs
41+
[`hyperpolymath/standards#100`](https://github.com/hyperpolymath/standards/issues/100),
42+
[`#91`](https://github.com/hyperpolymath/standards/issues/91).
43+
3244
- **k8s Service for BoJ is now `type: ClusterIP`** (was: `LoadBalancer`).
3345
Per ADR-0004 §1 and the Phase E rollout-runbook §1.4 prereq #8, BoJ
3446
must not be externally addressable when fronted by

elixir/config/config.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Config
44

55
config :boj_rest,
66
port: String.to_integer(System.get_env("BOJ_PORT") || "7700"),
7+
bind_ip: System.get_env("BOJ_BIND_IP") || "127.0.0.1",
78
cartridges_root:
89
System.get_env("BOJ_CARTRIDGES_ROOT") ||
910
Path.expand("../../cartridges", __DIR__),

elixir/lib/boj_rest/application.ex

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,27 @@ defmodule BojRest.Application do
66
77
Boots the cartridge catalog (reads every `cartridges/*/cartridge.json`
88
into an ETS-backed cache) and the Cowboy HTTP listener on the configured
9-
port (default 7700).
9+
port (default 7700) bound to the configured IP (default `127.0.0.1`).
10+
11+
## Bind address — loopback by default
12+
13+
The Cowboy listener binds to `127.0.0.1` unless `BOJ_BIND_IP` is set.
14+
This is the code-enforced expression of the ADR-0004 §1 invariant
15+
that BoJ's back-side bind is not externally routable in deployments
16+
fronted by http-capability-gateway. Operators wanting BoJ exposed
17+
on all interfaces (legacy / standalone deployments) must set
18+
`BOJ_BIND_IP=0.0.0.0` (IPv4) or `BOJ_BIND_IP=::` (IPv6) explicitly.
19+
20+
See `docs/integration/http-capability-gateway-boj-contract.md` §1
21+
for the wider transport invariant and the Phase E rollout-runbook
22+
§1.4 prerequisite checklist.
1023
"""
1124
use Application
1225

1326
@impl true
1427
def start(_type, _args) do
1528
port = Application.get_env(:boj_rest, :port, 7700)
29+
bind_ip = parse_bind_ip(Application.get_env(:boj_rest, :bind_ip, "127.0.0.1"))
1630
cartridges_root = Application.get_env(:boj_rest, :cartridges_root)
1731

1832
data_dir = Application.get_env(:boj_rest, :data_dir, "/data")
@@ -26,12 +40,32 @@ defmodule BojRest.Application do
2640
{BojRest.JsWorkerPool, []}
2741
] ++
2842
if start_server do
29-
[{Plug.Cowboy, scheme: :http, plug: BojRest.Router, options: [port: port]}]
43+
[{Plug.Cowboy, scheme: :http, plug: BojRest.Router, options: [port: port, ip: bind_ip]}]
3044
else
3145
[]
3246
end
3347

3448
opts = [strategy: :one_for_one, name: BojRest.Supervisor]
3549
Supervisor.start_link(children, opts)
3650
end
51+
52+
@doc """
53+
Parse a bind-address string (IPv4 or IPv6) into the Erlang inet tuple
54+
form expected by `:gen_tcp`/Cowboy. Raises on invalid input — fail-fast
55+
is preferred to silently degrading to `0.0.0.0` and exposing the
56+
back-side bind.
57+
"""
58+
@spec parse_bind_ip(String.t()) :: :inet.ip_address()
59+
def parse_bind_ip(str) when is_binary(str) do
60+
case :inet.parse_address(String.to_charlist(str)) do
61+
{:ok, ip} ->
62+
ip
63+
64+
{:error, _} ->
65+
raise ArgumentError,
66+
"BOJ_BIND_IP=#{inspect(str)} is not a valid IPv4 or IPv6 address. " <>
67+
"Use e.g. \"127.0.0.1\" (default, loopback-only) or \"0.0.0.0\" " <>
68+
"(all IPv4 interfaces; only valid for legacy/standalone deployments)."
69+
end
70+
end
3771
end

elixir/test/application_test.exs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
defmodule BojRest.ApplicationTest do
4+
use ExUnit.Case, async: true
5+
6+
alias BojRest.Application, as: App
7+
8+
describe "parse_bind_ip/1" do
9+
test "parses IPv4 loopback" do
10+
assert App.parse_bind_ip("127.0.0.1") == {127, 0, 0, 1}
11+
end
12+
13+
test "parses IPv4 all-interfaces" do
14+
assert App.parse_bind_ip("0.0.0.0") == {0, 0, 0, 0}
15+
end
16+
17+
test "parses IPv6 loopback" do
18+
assert App.parse_bind_ip("::1") == {0, 0, 0, 0, 0, 0, 0, 1}
19+
end
20+
21+
test "parses IPv6 all-interfaces" do
22+
assert App.parse_bind_ip("::") == {0, 0, 0, 0, 0, 0, 0, 0}
23+
end
24+
25+
test "raises ArgumentError on invalid input" do
26+
assert_raise ArgumentError, ~r/not a valid IPv4 or IPv6 address/, fn ->
27+
App.parse_bind_ip("not-an-ip")
28+
end
29+
end
30+
31+
test "raises ArgumentError on empty string" do
32+
assert_raise ArgumentError, ~r/not a valid IPv4 or IPv6 address/, fn ->
33+
App.parse_bind_ip("")
34+
end
35+
end
36+
37+
test "error message names the offending value" do
38+
assert_raise ArgumentError, ~r/garbage/, fn ->
39+
App.parse_bind_ip("garbage")
40+
end
41+
end
42+
end
43+
end

0 commit comments

Comments
 (0)