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
38 changes: 34 additions & 4 deletions src/server/server.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
47 changes: 47 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,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
Expand Down Expand Up @@ -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)
Expand Down
Loading