From c48a2ad62c8b7a7542057d5a4422d07f892a39b2 Mon Sep 17 00:00:00 2001
From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com>
Date: Wed, 20 May 2026 10:43:01 +0100
Subject: [PATCH] fix(boj): bind Cowboy to 127.0.0.1 by default (HCG tier-2 E1
 prereq)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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
hyperpolymath/standards#100 — pre-staged ahead of E1/E2 wiring so it
ships independent of Phase D's still-scaffolded benchmark gate.

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.

Tests: 7 new in `elixir/test/application_test.exs` covering IPv4
loopback, IPv4 all-interfaces, IPv6 loopback, IPv6 all-interfaces,
invalid input, empty string, and that the error message names the
offending value.

Baseline-rot note: `mix test` shows 2 pre-existing property-test
failures on `whisper-mcp` cartridge having `auth.method:
"optional_api_key"` (catalog_properties_test.exs). Verified present on
`origin/main` (commit 92f3b3a1) — not introduced here. Out of scope
for this PR.

Refs hyperpolymath/standards#100
Refs hyperpolymath/standards#91

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 CHANGELOG.md                       | 14 ++++++++++
 elixir/config/config.exs           |  1 +
 elixir/lib/boj_rest/application.ex | 38 ++++++++++++++++++++++++--
 elixir/test/application_test.exs   | 43 ++++++++++++++++++++++++++++++
 4 files changed, 94 insertions(+), 2 deletions(-)
 create mode 100644 elixir/test/application_test.exs

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a06b2ca..1932693d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,20 @@ All notable changes to Bundle of Joy Server are documented here.
 
 ## [Unreleased]
 
+### Changed
+
+- **Cowboy listener now binds to `127.0.0.1` by default** (was: all
+  interfaces). Configurable via the `BOJ_BIND_IP` environment variable;
+  invalid values fail-fast at boot rather than silently falling back to
+  `0.0.0.0`. This is the code-enforced expression of 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). Phase E
+  rollout-runbook §1.4 prerequisite #6. Legacy/standalone deployments
+  that want all-interfaces exposure must now opt in explicitly
+  (`BOJ_BIND_IP=0.0.0.0` or `BOJ_BIND_IP=::`). Refs
+  [`hyperpolymath/standards#100`](https://github.com/hyperpolymath/standards/issues/100),
+  [`#91`](https://github.com/hyperpolymath/standards/issues/91).
+
 ### Added
 
 - **ADR-0014 — cross-cartridge composition safety (RFC)** — frames the
diff --git a/elixir/config/config.exs b/elixir/config/config.exs
index a5a76097..5f8204b7 100644
--- a/elixir/config/config.exs
+++ b/elixir/config/config.exs
@@ -4,6 +4,7 @@ import Config
 
 config :boj_rest,
   port: String.to_integer(System.get_env("BOJ_PORT") || "7700"),
+  bind_ip: System.get_env("BOJ_BIND_IP") || "127.0.0.1",
   cartridges_root:
     System.get_env("BOJ_CARTRIDGES_ROOT") ||
       Path.expand("../../cartridges", __DIR__),
diff --git a/elixir/lib/boj_rest/application.ex b/elixir/lib/boj_rest/application.ex
index 3d932c21..4993bfe8 100644
--- a/elixir/lib/boj_rest/application.ex
+++ b/elixir/lib/boj_rest/application.ex
@@ -6,13 +6,27 @@ defmodule BojRest.Application do
 
   Boots the cartridge catalog (reads every `cartridges/*/cartridge.json`
   into an ETS-backed cache) and the Cowboy HTTP listener on the configured
-  port (default 7700).
+  port (default 7700) bound to the configured IP (default `127.0.0.1`).
+
+  ## Bind address — loopback by default
+
+  The Cowboy listener binds to `127.0.0.1` unless `BOJ_BIND_IP` is set.
+  This is the code-enforced expression of the ADR-0004 §1 invariant
+  that BoJ's back-side bind is not externally routable in deployments
+  fronted by http-capability-gateway. Operators wanting BoJ exposed
+  on all interfaces (legacy / standalone deployments) must set
+  `BOJ_BIND_IP=0.0.0.0` (IPv4) or `BOJ_BIND_IP=::` (IPv6) explicitly.
+
+  See `docs/integration/http-capability-gateway-boj-contract.md` §1
+  for the wider transport invariant and the Phase E rollout-runbook
+  §1.4 prerequisite checklist.
   """
   use Application
 
   @impl true
   def start(_type, _args) do
     port = Application.get_env(:boj_rest, :port, 7700)
+    bind_ip = parse_bind_ip(Application.get_env(:boj_rest, :bind_ip, "127.0.0.1"))
     cartridges_root = Application.get_env(:boj_rest, :cartridges_root)
 
     data_dir = Application.get_env(:boj_rest, :data_dir, "/data")
@@ -26,7 +40,7 @@ defmodule BojRest.Application do
         {BojRest.JsWorkerPool, []}
       ] ++
         if start_server do
-          [{Plug.Cowboy, scheme: :http, plug: BojRest.Router, options: [port: port]}]
+          [{Plug.Cowboy, scheme: :http, plug: BojRest.Router, options: [port: port, ip: bind_ip]}]
         else
           []
         end
@@ -34,4 +48,24 @@ defmodule BojRest.Application do
     opts = [strategy: :one_for_one, name: BojRest.Supervisor]
     Supervisor.start_link(children, opts)
   end
+
+  @doc """
+  Parse a bind-address string (IPv4 or IPv6) into the Erlang inet tuple
+  form expected by `:gen_tcp`/Cowboy. Raises on invalid input — fail-fast
+  is preferred to silently degrading to `0.0.0.0` and exposing the
+  back-side bind.
+  """
+  @spec parse_bind_ip(String.t()) :: :inet.ip_address()
+  def parse_bind_ip(str) when is_binary(str) do
+    case :inet.parse_address(String.to_charlist(str)) do
+      {:ok, ip} ->
+        ip
+
+      {:error, _} ->
+        raise ArgumentError,
+              "BOJ_BIND_IP=#{inspect(str)} is not a valid IPv4 or IPv6 address. " <>
+                "Use e.g. \"127.0.0.1\" (default, loopback-only) or \"0.0.0.0\" " <>
+                "(all IPv4 interfaces; only valid for legacy/standalone deployments)."
+    end
+  end
 end
diff --git a/elixir/test/application_test.exs b/elixir/test/application_test.exs
new file mode 100644
index 00000000..39930d42
--- /dev/null
+++ b/elixir/test/application_test.exs
@@ -0,0 +1,43 @@
+# SPDX-License-Identifier: PMPL-1.0-or-later
+# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
+defmodule BojRest.ApplicationTest do
+  use ExUnit.Case, async: true
+
+  alias BojRest.Application, as: App
+
+  describe "parse_bind_ip/1" do
+    test "parses IPv4 loopback" do
+      assert App.parse_bind_ip("127.0.0.1") == {127, 0, 0, 1}
+    end
+
+    test "parses IPv4 all-interfaces" do
+      assert App.parse_bind_ip("0.0.0.0") == {0, 0, 0, 0}
+    end
+
+    test "parses IPv6 loopback" do
+      assert App.parse_bind_ip("::1") == {0, 0, 0, 0, 0, 0, 0, 1}
+    end
+
+    test "parses IPv6 all-interfaces" do
+      assert App.parse_bind_ip("::") == {0, 0, 0, 0, 0, 0, 0, 0}
+    end
+
+    test "raises ArgumentError on invalid input" do
+      assert_raise ArgumentError, ~r/not a valid IPv4 or IPv6 address/, fn ->
+        App.parse_bind_ip("not-an-ip")
+      end
+    end
+
+    test "raises ArgumentError on empty string" do
+      assert_raise ArgumentError, ~r/not a valid IPv4 or IPv6 address/, fn ->
+        App.parse_bind_ip("")
+      end
+    end
+
+    test "error message names the offending value" do
+      assert_raise ArgumentError, ~r/garbage/, fn ->
+        App.parse_bind_ip("garbage")
+      end
+    end
+  end
+end
