From 1b959ca37a2a30018bfb0f4a7ee3c2fb171b7d05 Mon Sep 17 00:00:00 2001 From: Homer Quan Date: Mon, 18 May 2026 13:39:52 -0400 Subject: [PATCH] Fix gRPC bind host fail-open --- README.md | 2 +- lib/mirror_neuron/application.ex | 30 ++++++++++++++++++++++++++---- tests/unit/application_test.exs | 25 +++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 tests/unit/application_test.exs diff --git a/README.md b/README.md index 2569174..6e6e1c1 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ Runtime configuration is read from environment variables in `config/runtime.exs` | `MN_NODE_EXECUTION_PROFILES` | Empty | Comma-separated execution profiles this runtime node may advertise after warmup. Empty means the node advertises no profiled executors. | | `MN_NODE_CAPABILITIES` | Empty | Comma-separated runtime capabilities such as `video-codec:h264` or `ffmpeg`. | | `MN_NODE_GPU` | Auto-detected | Optional override for whether this runtime node advertises GPU capacity. | -| `MN_CORE_HOST` | `localhost` | Host/IP used by the gRPC listener. | +| `MN_CORE_HOST` | `localhost` | Host/IP used by the gRPC listener. Empty, `localhost`, and invalid hostnames bind to `127.0.0.1`; use an IP literal such as `0.0.0.0` to listen on all interfaces. | | `MN_GRPC_PORT` | `50051` | gRPC port. | | `MN_API_ENABLED` | `true` | Enables API-related runtime config. | | `MN_API_PORT` | `4000` | Core API config port. The separate `mn-api` package uses its own defaults. | diff --git a/lib/mirror_neuron/application.ex b/lib/mirror_neuron/application.ex index 34a1dc2..bbffcdf 100644 --- a/lib/mirror_neuron/application.ex +++ b/lib/mirror_neuron/application.ex @@ -1,6 +1,8 @@ defmodule MirrorNeuron.Application do use Application + require Logger + alias MirrorNeuron.Config @impl true @@ -73,11 +75,23 @@ defmodule MirrorNeuron.Application do System.get_env("MN_NODE_ROLE", "runtime") end - defp grpc_bind_opts(host) when host in ["", "localhost"] do - [ip: {127, 0, 0, 1}] + @doc false + def grpc_bind_opts(host) do + normalized_host = + host + |> to_string() + |> String.trim() + + case normalized_host do + "" -> + [ip: {127, 0, 0, 1}] + + host -> + parse_grpc_bind_host(host) + end end - defp grpc_bind_opts(host) do + defp parse_grpc_bind_host(host) do case :inet.parse_address(String.to_charlist(host)) do {:ok, address} when tuple_size(address) == 4 -> [ip: address] @@ -86,7 +100,15 @@ defmodule MirrorNeuron.Application do [net: :inet6, ip: address] _ -> - [] + grpc_loopback_opts(host) end end + + defp grpc_loopback_opts(host) do + unless String.downcase(host) == "localhost" do + Logger.warning("Invalid MN_CORE_HOST #{inspect(host)}; binding gRPC listener to 127.0.0.1") + end + + [ip: {127, 0, 0, 1}] + end end diff --git a/tests/unit/application_test.exs b/tests/unit/application_test.exs new file mode 100644 index 0000000..2881907 --- /dev/null +++ b/tests/unit/application_test.exs @@ -0,0 +1,25 @@ +defmodule MirrorNeuron.ApplicationTest do + use ExUnit.Case, async: true + + alias MirrorNeuron.Application + + describe "grpc_bind_opts/1" do + test "binds empty and localhost values to IPv4 loopback" do + assert Application.grpc_bind_opts("") == [ip: {127, 0, 0, 1}] + assert Application.grpc_bind_opts("localhost") == [ip: {127, 0, 0, 1}] + assert Application.grpc_bind_opts("LOCALHOST") == [ip: {127, 0, 0, 1}] + assert Application.grpc_bind_opts(" localhost ") == [ip: {127, 0, 0, 1}] + end + + test "passes through IP literal bind hosts" do + assert Application.grpc_bind_opts("0.0.0.0") == [ip: {0, 0, 0, 0}] + assert Application.grpc_bind_opts("127.0.0.1") == [ip: {127, 0, 0, 1}] + assert Application.grpc_bind_opts("::1") == [net: :inet6, ip: {0, 0, 0, 0, 0, 0, 0, 1}] + end + + test "fails closed to IPv4 loopback for invalid bind hosts" do + assert Application.grpc_bind_opts("example.com") == [ip: {127, 0, 0, 1}] + assert Application.grpc_bind_opts("not an ip") == [ip: {127, 0, 0, 1}] + end + end +end