From 525c2808be4c7471f925caa263db6eb783cca9b3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" Date: Thu, 15 Oct 2026 01:56:44 +0100 Subject: [PATCH] fix(server): admit the reached IP on a wildcard bind; isolate the origin tests from the environment Both CodeRabbit functional-correctness findings from PR #120, accepted. Wildcard bind: the origin allow-list excludes wildcard addresses and the Host check runs on loopback binds only, so a UI served from a wildcard bind (start(host="0.0.0.0")) received 403 on every write: the browser sends Origin on same-origin POSTs, and the served address (http://192.168.1.10:8080) was not in _allowed_origins(). The operator's only recourse was to list the machine's address in JULIA_METAMANIFOLD_ALLOWED_ORIGINS, which the README documents for split deployments but which makes the plain wildcard-served UI unusable for writes. The guard now admits, on a wildcard bind, an origin that names the request's own IP: an http origin whose host is an IP literal (IPv4 or bracketed IPv6, never a name), on the bound port, whose host-and-port equals the Host header. A browser sends such an Origin only for a page it loaded from exactly that address, so the request is same-origin by construction; a DNS-rebinding page arrives with the attacker's domain as both Host and Origin, and a name is not an IP literal, so it is refused as before. Hostnames and proxied ports must still be listed in JULIA_METAMANIFOLD_ALLOWED_ORIGINS. Test isolation: the baseline origin assertions in test_server_security.jl assumed JULIA_METAMANIFOLD_ALLOWED_ORIGINS was unset; the suite failed for a developer running it with the variable set (a supported deployment setting). It is now cleared before the testset and restored in the finally. Tests: a new wildcard-bind testset covers the admitted IP (GET with CORS echo, preflight 204, a POST that reaches the route), the admitted IPv6 literal, other addresses, ports and schemes refused, the rebinding shape refused, and a listed hostname required without the variable. Verified locally both with the variable unset (86/86 in the file, 2636 passed in the full suite) and set to "http://localhost:3000, http://192.168.1.10:8080" (still 86/86; the same run on main fails 4 tests). Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- src/server/server.jl | 38 ++++++++++++++++++++++--- test/unit/test_server_security.jl | 47 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/server/server.jl b/src/server/server.jl index 0e8c2afd..880a3f84 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -245,15 +245,43 @@ module Server allowed end + _is_wildcard(h::AbstractString) = lowercase(h) in ("0.0.0.0", "::", "[::]") + + """ + _bound_ip_origin(origin, host_header) -> Bool + + Whether `origin` names the exact IP address the browser reached a wildcard + bind on: an `http://` origin whose host is an IP literal (not a name), whose + port is the bound port, and whose host-and-port equals the request's `Host` + header. A browser only sends such an `Origin` for a page it loaded from that + address, so the request is same-origin by construction. A DNS-rebinding page + arrives with the attacker's domain as both `Host` and `Origin`; a name is not + an IP literal, so it is refused here as before. Hostnames (e.g. + `http://lab-box:8080`) and any address behind a proxy on another port must be + listed in `JULIA_METAMANIFOLD_ALLOWED_ORIGINS`. + """ + function _bound_ip_origin(origin::AbstractString, host_header::AbstractString) + o = _normalise_origin(origin) + startswith(o, "http://") || return false + hostport = o[length("http://") + 1:end] + hostport == lowercase(strip(host_header)) || return false + m = match(r"^(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-f:]+\]):(\d+)$"i, hostport) + !isnothing(m) && parse(Int, m.captures[1]) == _bound_port[] + end + """ _cors_middleware(next) Return a request handler that answers 403 for a non-empty, non-loopback Host on a loopback bind, or a non-empty `Origin` outside `_allowed_origins()` after - normalisation. The Host check also applies to requests without an Origin. - Allowed origins get CORS headers echoing the supplied Origin; OPTIONS gets - 204 without calling `next`. Otherwise call `next(req)`, adding CORS headers - only when the Origin is non-empty. Errors from `next` propagate. + normalisation. On a wildcard bind an origin naming the request's own IP host + on the bound port is also admitted (`_bound_ip_origin`): the UI is then + served, and writable, at whatever address the machine is reached on, without + trusting a hostname from the `Host` header. The Host check also applies to + requests without an Origin. Allowed origins get CORS headers echoing the + supplied Origin; OPTIONS gets 204 without calling `next`. Otherwise call + `next(req)`, adding CORS headers only when the Origin is non-empty. Errors + from `next` propagate. """ function _cors_middleware(next) function(req::HTTP.Request) @@ -268,6 +296,8 @@ module Server return next(req) end _normalise_origin(origin) in _allowed_origins() || + (_is_wildcard(_bound_host[]) && + _bound_ip_origin(origin, HTTP.header(req, "Host", ""))) || return HTTP.Response(403, "Forbidden: origin not allowed") cors_headers = [ "Access-Control-Allow-Origin" => origin, diff --git a/test/unit/test_server_security.jl b/test/unit/test_server_security.jl index 2bb7086d..9ef2fad6 100644 --- a/test/unit/test_server_security.jl +++ b/test/unit/test_server_security.jl @@ -34,6 +34,14 @@ end SV.ServerState.set_root!(tmp) SV._bound_host[] = "127.0.0.1" SV._bound_port[] = 8080 + # The baseline assertions below hold only with no extra origins + # configured. JULIA_METAMANIFOLD_ALLOWED_ORIGINS is a supported + # deployment setting (split deployments, tunnels), and a developer + # running the suite with it set would otherwise fail them, so it is + # cleared here and restored in the finally. + saved_origins = get(ENV, "JULIA_METAMANIFOLD_ALLOWED_ORIGINS", nothing) + haskey(ENV, "JULIA_METAMANIFOLD_ALLOWED_ORIGINS") && + delete!(ENV, "JULIA_METAMANIFOLD_ALLOWED_ORIGINS") mkpath(joinpath(tmp, "data", "StudyS", "run1")) touch(joinpath(tmp, "data", "StudyS", "run1", "s1_R1.fastq.gz")) mkpath(joinpath(tmp, "projects", "StudyS", "run1")) @@ -77,6 +85,43 @@ end @test _ss_request("GET", studies, ["Host" => "evil.example:8080"]).status == 403 end + @testset "Wildcard bind serves, and admits only, the reached IP" begin + studies = "/api/v1/studies" + SV._bound_host[] = "0.0.0.0" + lan = "Host" => "192.168.1.10:8080" + ui = "Origin" => "http://192.168.1.10:8080" + # The UI served at the machine's address: its origin is admitted, + # with CORS headers, a preflight answer, and writes that reach the + # route (this one fails in the route itself, not in the guard). + r = _ss_request("GET", studies, [lan, ui]) + @test r.status == 200 + @test SV.HTTP.header(r, "Access-Control-Allow-Origin") == "http://192.168.1.10:8080" + @test _ss_request("OPTIONS", studies, [lan, ui]).status == 204 + @test _ss_request("POST", "/api/v1/studies/StudyS/report?kind=bogus&ext=.txt", + [lan, ui, "Content-Type" => "text/plain"], "x").status == 400 + @test !isdir(joinpath(tmp, "projects", "StudyS", "report")) + # An IPv6 literal that matches its Host is admitted the same way. + @test _ss_request("GET", studies, + ["Host" => "[fd00::10]:8080", "Origin" => "http://[fd00::10]:8080"]).status == 200 + # Another address, another port, or another scheme: still refused. + for o in ("http://192.168.1.11:8080", "http://192.168.1.10:9090", + "https://192.168.1.10:8080") + @test _ss_request("GET", studies, [lan, "Origin" => o]).status == 403 + end + # A DNS-rebinding page: the domain matches its own Host, but a + # name is not an IP literal, so it is still refused. + @test _ss_request("GET", studies, + ["Host" => "evil.example:8080", "Origin" => "http://evil.example:8080"]).status == 403 + # A hostname the machine is reached by still has to be listed. + withenv("JULIA_METAMANIFOLD_ALLOWED_ORIGINS" => "http://lab-box:8080") do + @test _ss_request("GET", studies, + ["Host" => "lab-box:8080", "Origin" => "http://lab-box:8080"]).status == 200 + end + @test _ss_request("GET", studies, + ["Host" => "lab-box:8080", "Origin" => "http://lab-box:8080"]).status == 403 + SV._bound_host[] = "127.0.0.1" + end + @testset "Oxygen docs and metrics are not served" begin @test SV._SERVE_OPTIONS.docs === false @test SV._SERVE_OPTIONS.metrics === false @@ -121,6 +166,8 @@ end @test SV.HTTP.header(f, "Content-Security-Policy") == "" end finally + saved_origins === nothing || + (ENV["JULIA_METAMANIFOLD_ALLOWED_ORIGINS"] = saved_origins) SV.ServerState._root[] = old_root SV._bound_host[], SV._bound_port[] = old_host, old_port rm(tmp; recursive=true, force=true)