From 888f63f44bbbf7a64b7a1a0e0ee47c5c3784da55 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:12:04 +0100 Subject: [PATCH 1/3] fix(server): isolate wildcard IP origin handling for validation Recover b672663 with the origin tests, excluding unrelated documentation. --- src/server/server.jl | 33 ++++++++++++++++++++-- test/unit/test_server_security.jl | 47 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/server/server.jl b/src/server/server.jl index cef70d33..843cef6b 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -241,12 +241,37 @@ 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) - Refuse requests whose Host is not loopback on a loopback bind, and requests - carrying an `Origin` outside `_allowed_origins()`. Allowed origins get CORS - headers and preflight answers; requests with no `Origin` pass unchanged. + Refuse unexpected hosts on loopback binds and origins outside the allowlist. + On wildcard binds, also admit an HTTP origin matching the request's literal + IP address and bound port. Domain names still require explicit configuration. + Requests without an Origin continue through the host guard. """ function _cors_middleware(next) function(req::HTTP.Request) @@ -261,6 +286,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) From 72d188e636b3ef0897e43ac6560acd04bf091edc Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:12:15 +0100 Subject: [PATCH 2/3] fix(server): refuse overflowing origin ports without throwing Use tryparse for request-controlled port text and add a regression test. Native Julia test execution remains pending. --- src/server/server.jl | 4 +++- test/unit/test_server_security.jl | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/server/server.jl b/src/server/server.jl index 843cef6b..fb356697 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -262,7 +262,9 @@ module Server 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[] + isnothing(m) && return false + port = tryparse(Int, m.captures[1]) + !isnothing(port) && port == _bound_port[] end """ diff --git a/test/unit/test_server_security.jl b/test/unit/test_server_security.jl index 9ef2fad6..dbb4ef2b 100644 --- a/test/unit/test_server_security.jl +++ b/test/unit/test_server_security.jl @@ -122,6 +122,11 @@ end SV._bound_host[] = "127.0.0.1" end + @testset "Oversized origin ports are refused without throwing" begin + host = "192.168.1.10:" * repeat("9", 100) + @test !SV._bound_ip_origin("http://" * host, host) + end + @testset "Oxygen docs and metrics are not served" begin @test SV._SERVE_OPTIONS.docs === false @test SV._SERVE_OPTIONS.metrics === false From 2c64676874dbbff61b59e2e4b6fdd9e2e4864df6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:31:14 +0100 Subject: [PATCH 3/3] fix(server): accept implicit HTTP port 80 on wildcard IP origins --- src/server/server.jl | 4 ++-- test/unit/test_server_security.jl | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/server/server.jl b/src/server/server.jl index fb356697..d5673f9c 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -261,9 +261,9 @@ module Server 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) + m = match(r"^(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-f:]+\])(?::(\d+))?$"i, hostport) isnothing(m) && return false - port = tryparse(Int, m.captures[1]) + port = isnothing(m.captures[1]) ? 80 : tryparse(Int, m.captures[1]) !isnothing(port) && port == _bound_port[] end diff --git a/test/unit/test_server_security.jl b/test/unit/test_server_security.jl index dbb4ef2b..b108de6e 100644 --- a/test/unit/test_server_security.jl +++ b/test/unit/test_server_security.jl @@ -122,6 +122,22 @@ end SV._bound_host[] = "127.0.0.1" end + @testset "Implicit HTTP port on wildcard binds" begin + SV._bound_host[] = "0.0.0.0" + SV._bound_port[] = 80 + for host in ("192.168.1.10", "[2001:db8::1]") + @test SV._bound_ip_origin("http://" * host, host) + r = _ss_request("OPTIONS", "/api/v1/studies", + ["Host" => host, "Origin" => "http://" * host]) + @test r.status == 204 + @test !SV._bound_ip_origin("https://" * host, host) + end + @test !SV._bound_ip_origin("http://attacker.example", "attacker.example") + SV._bound_port[] = 8080 + @test !SV._bound_ip_origin("http://192.168.1.10", "192.168.1.10") + SV._bound_host[] = "127.0.0.1" + end + @testset "Oversized origin ports are refused without throwing" begin host = "192.168.1.10:" * repeat("9", 100) @test !SV._bound_ip_origin("http://" * host, host)