Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions src/server/server.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
68 changes: 68 additions & 0 deletions test/unit/test_server_security.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading