diff --git a/src/server/server.jl b/src/server/server.jl index cef70d33..d5673f9c 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -241,12 +241,39 @@ 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) && return false + port = isnothing(m.captures[1]) ? 80 : tryparse(Int, m.captures[1]) + !isnothing(port) && port == _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 +288,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..b108de6e 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,64 @@ 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 "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) + end + @testset "Oxygen docs and metrics are not served" begin @test SV._SERVE_OPTIONS.docs === false @test SV._SERVE_OPTIONS.metrics === false @@ -121,6 +187,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)