diff --git a/README.md b/README.md index bb9883cc..1fe02ca8 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ The web UI lets you create studies, configure pipeline parameters, launch runs, | `JULIA_METAMANIFOLD_PORT` | `8080` | Server port | | `JULIA_METAMANIFOLD_ROOT` | working directory | Project root | | `JULIA_THREADS` | `8` | Julia threads | +| `JULIA_METAMANIFOLD_ALLOWED_ORIGINS` | none | Extra browser origins allowed to call the API, comma-separated (the server's own origin and the Vite dev server at `localhost:5173` are always allowed) | Or run the Julia server directly: @@ -644,6 +645,30 @@ bash start.sh Open `http://localhost:8080`. The backend serves the frontend. +### Split deployment (frontend and API on different origins) + +To serve the frontend from a different address than the API, set `apiBase` in +`frontend/public/config.json` to the API's address, and list the frontend's +address when starting the server: + +```bash +JULIA_METAMANIFOLD_ALLOWED_ORIGINS=https://frontend.example bash start.sh +``` + +The server accepts browser requests from its own origin (`http://localhost:` +and `http://127.0.0.1:`), from the Vite dev server (`localhost:5173`), and +from the origins listed in `JULIA_METAMANIFOLD_ALLOWED_ORIGINS`, separated by +commas. An origin has to match exactly, scheme and port included: +`https://frontend.example` does not cover `http://frontend.example` or +`https://frontend.example:8443`. A page on any other origin gets +`403 Forbidden: origin not allowed` on every request that carries an `Origin` +header, which is every write and every cross-origin read. + +This also applies to an SSH tunnel on a different local port (e.g. +`http://localhost:9000`): the browser's origin is then `http://localhost:9000`, +not the server's `http://localhost:8080`, so start the server with +`JULIA_METAMANIFOLD_ALLOWED_ORIGINS=http://localhost:9000`. + ## Input data Place paired-end FASTQ files under `data/{project_name}/` following Illumina naming: diff --git a/frontend/src/components/DifferentialPanel.tsx b/frontend/src/components/DifferentialPanel.tsx index 8f78e326..825fb812 100644 --- a/frontend/src/components/DifferentialPanel.tsx +++ b/frontend/src/components/DifferentialPanel.tsx @@ -22,7 +22,7 @@ function csvBlob(result: DifferentialResult): Blob { /** * Differential abundance between exactly two runs or sub-groups: a model per - * taxon at the chosen rank (negative-binomial GLM or Welch's t-test on CLR values, as + * taxon at the chosen rank (negative-binomial GLM or CLR linear model, as * configured), with BH-adjusted p-values, shown as a volcano plot and a table * that can be downloaded or added to the report. The first selected condition is the reference. */ diff --git a/src/server/routes/report.jl b/src/server/routes/report.jl index b3c37fa6..73ac45a6 100644 --- a/src/server/routes/report.jl +++ b/src/server/routes/report.jl @@ -126,6 +126,12 @@ const _REPORT_MIME = Dict(".svg" => "image/svg+xml", ".png" => "image/png", ".js ".pdf" => "application/pdf", ".tif" => "image/tiff", ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") +# Uploaded items are stored as sent, so an .svg can carry script. Served with +# these, a file opened directly cannot run it or be sniffed as another type; +# an showing it is unaffected. +const _UPLOAD_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox" +const _UPLOAD_HEADERS = ["X-Content-Type-Options" => "nosniff", "Content-Security-Policy" => _UPLOAD_CSP] + @get "/api/v1/studies/{study}/report/{id}/file" function(req, study::String, id::String) err = _report_study_error(study) isnothing(err) || return err @@ -134,7 +140,8 @@ const _REPORT_MIME = Dict(".svg" => "image/svg+xml", ".png" => "image/png", ".js it = _report_items(study)[item] path = joinpath(_report_dir(study), it["file"]) isfile(path) || return json_error(404, "not_found", "The file for '$id' is missing") - HTTP.Response(200, ["Content-Type" => get(_REPORT_MIME, lowercase(splitext(path)[2]), "application/octet-stream")]; + HTTP.Response(200, ["Content-Type" => get(_REPORT_MIME, lowercase(splitext(path)[2]), "application/octet-stream"), + _UPLOAD_HEADERS...]; body=read(path)) end diff --git a/src/server/server.jl b/src/server/server.jl index 56537a71..0e8c2afd 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -207,8 +207,54 @@ module Server ## CORS middleware (needed when the frontend is served from a different origin) # Loopback binds also check Host, so a DNS-rebinding page cannot read the API. const _bound_host = Ref("127.0.0.1") + const _bound_port = Ref(8080) _is_loopback(h::AbstractString) = lowercase(h) in ("localhost", "127.0.0.1", "::1", "[::1]") + # The Vite dev server (frontend/vite.config.ts, default port) proxies /api and + # /files here without rewriting Origin, so its pages arrive with this origin. + const _DEV_ORIGINS = ("http://localhost:5173", "http://127.0.0.1:5173") + + "An origin in the form `_allowed_origins` holds: lowercase, no trailing slash." + _normalise_origin(o::AbstractString) = rstrip(lowercase(strip(o)), '/') + + """ + _allowed_origins() -> Set{String} + + The browser origins allowed to call the API cross-origin: the origin this + server is served from over HTTP (its bound port, spelled with `localhost` and + `127.0.0.1`, plus the bound host itself when it is a specific name), the Vite + dev server, and any listed comma-separated in + `JULIA_METAMANIFOLD_ALLOWED_ORIGINS`. Every other origin is refused, including + other ports and schemes on localhost. + + Wildcard bind addresses are not included. Read the environment on each call, + ignoring blank entries and normalising case, surrounding whitespace and + trailing slashes; entries are not validated as URLs. + """ + function _allowed_origins() + port = _bound_port[] + host = lowercase(_bound_host[]) + hosts = Set(["localhost", "127.0.0.1"]) + host in ("0.0.0.0", "::", "[::]") || + push!(hosts, occursin(':', host) && !startswith(host, "[") ? "[$host]" : host) + allowed = Set(["http://$h:$port" for h in hosts]) + union!(allowed, _DEV_ORIGINS) + for o in split(get(ENV, "JULIA_METAMANIFOLD_ALLOWED_ORIGINS", ""), ',') + isempty(strip(o)) || push!(allowed, _normalise_origin(o)) + end + allowed + 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. + """ function _cors_middleware(next) function(req::HTTP.Request) if _is_loopback(_bound_host[]) @@ -221,11 +267,8 @@ module Server # Same-origin request - no CORS headers needed return next(req) end - # Only allow localhost origins - origin_url = try HTTP.URIs.URI(origin) catch; nothing end - if isnothing(origin_url) || !(lowercase(origin_url.host) in ("localhost", "127.0.0.1", "::1")) - return HTTP.Response(403, "Forbidden: non-localhost origin") - end + _normalise_origin(origin) in _allowed_origins() || + return HTTP.Response(403, "Forbidden: origin not allowed") cors_headers = [ "Access-Control-Allow-Origin" => origin, "Access-Control-Allow-Methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS", @@ -252,7 +295,18 @@ module Server ".csv" => "text/csv", ".json" => "application/json", ) - # Static file serving middleware (Oxygen middleware signature: handler -> req -> response) + """ + _file_middleware(next) + + Serve run outputs under `/files/{study}/runs/...` from the projects tree and + the frontend build for other non-API paths, passing `/api/` requests to `next`. + Return a request handler; query strings are ignored when locating files. + Missing run files return 404; existing files resolving outside the projects + tree return 403. For other paths, serve a matching frontend file or fall back + to `index.html`, calling `next` if neither exists. + Run files are sent with `nosniff`; report uploads also get `_UPLOAD_CSP`. + File resolution and read errors, and errors from `next`, propagate. + """ function _file_middleware(next) function(req::HTTP.Request) uri = first(split(req.target, '?'; limit=2)) @@ -269,7 +323,13 @@ module Server return HTTP.Response(403, "Forbidden") ext = last(splitext(full)) mime = get(_mime_map, ext, "application/octet-stream") - return HTTP.Response(200, ["Content-Type" => mime]; body=read(full)) + # Report items are uploaded bytes, reachable here as well as + # through the report route: serve them as inert as that route does. + study_report = joinpath(projects, HTTP.URIs.unescapeuri(m[1]), "report") + uploaded = startswith(full, study_report * Base.Filesystem.path_separator) + headers = ["Content-Type" => mime, "X-Content-Type-Options" => "nosniff"] + uploaded && push!(headers, "Content-Security-Policy" => _UPLOAD_CSP) + return HTTP.Response(200, headers; body=read(full)) end # Pass API requests through to Oxygen @@ -342,10 +402,30 @@ module Server end ## Entry point + const _MIDDLEWARE = [_bad_request_middleware, _r_busy_middleware, _cors_middleware, + _analysis_source_middleware, _file_middleware] + # Oxygen's docs (/docs, /docs/schema) and metrics sit outside the middleware + # above, so they would bypass the Host and Origin checks; nothing uses them. + const _SERVE_OPTIONS = (access_log=nothing, show_errors=false, docs=false, metrics=false) + + """ + start(; root=pwd(), host="127.0.0.1", port=8080, listen=true) + + Set the server's root for `data/`, `projects/` and `config/`, record the bind + host and port for request guards, and initialise missing study projects. + Individual project creation failures are caught and startup continues; + errors enumerating studies propagate. + + With `listen=false`, perform this initialisation and return `nothing` without + opening a listener. Otherwise serve the API and frontend with the request + guards enabled and Oxygen docs and metrics disabled, waiting until the server + stops and returning its server object. Listener startup errors propagate. + """ function start(; root=pwd(), host="127.0.0.1", port=8080, listen::Bool=true) global_logger(_SuppressEpipe(global_logger())) ServerState.set_root!(root) _bound_host[] = host + _bound_port[] = port @info "MetaManifold server starting" root host port # Initialise all projects on startup @@ -354,10 +434,7 @@ module Server listen || return nothing _workload_enabled() || errormonitor(@async _warm_routes(host, port)) - serve(; host, port, access_log=nothing, - middleware=[_bad_request_middleware, _r_busy_middleware, _cors_middleware, - _analysis_source_middleware, _file_middleware], - show_errors=false) + serve(; host, port, middleware=_MIDDLEWARE, _SERVE_OPTIONS...) end """ diff --git a/test/runtests.jl b/test/runtests.jl index 73921614..d107e559 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -53,6 +53,7 @@ const UNIT_FILES = [ "test_differential.jl", "test_permanova.jl", "test_write_gates.jl", + "test_server_security.jl", ] # The files named on the command line ("test_routes.jl", "routes" or diff --git a/test/unit/test_server_security.jl b/test/unit/test_server_security.jl new file mode 100644 index 00000000..2bb7086d --- /dev/null +++ b/test/unit/test_server_security.jl @@ -0,0 +1,128 @@ +# Unit tests for the server's request guards: the Origin allow-list, Oxygen's +# docs and metrics being off, and the headers report uploads are served with. +# Requests go through the app middleware in-process; no socket is opened. +using MetaManifold +SV = MetaManifold.Server +using JSON3 + +# A request through the middleware `start` installs. +_ss_request(method, path, headers=Pair{String,String}[], body="") = + SV.Oxygen.internalrequest(SV.HTTP.Request(method, path, headers, body); middleware=SV._MIDDLEWARE) + +# A request through the handler Oxygen's `serve` builds from `_SERVE_OPTIONS`, +# docs and metrics included, without starting the listener. +function _ss_served(path) + ctx = SV.Oxygen.CONTEXT[] + opts = SV._SERVE_OPTIONS + saved = ctx.docs.router[] + try + ctx.docs.router[] = SV.HTTP.Router() + opts.docs && SV.Oxygen.Core.setupdocs(ctx) + opts.metrics && SV.Oxygen.Core.setupmetrics(ctx) + handler = SV.Oxygen.Core.setupmiddleware(ctx; middleware=SV._MIDDLEWARE, + docs=opts.docs, metrics=opts.metrics, show_errors=opts.show_errors) + handler(SV.HTTP.Request("GET", path, ["Host" => "127.0.0.1:8080"])) + finally + ctx.docs.router[] = saved + end +end + +@testset "Server request guards" begin + tmp = mktempdir() + old_root = SV.ServerState._root[] + old_host, old_port = SV._bound_host[], SV._bound_port[] + SV.ServerState.set_root!(tmp) + SV._bound_host[] = "127.0.0.1" + SV._bound_port[] = 8080 + mkpath(joinpath(tmp, "data", "StudyS", "run1")) + touch(joinpath(tmp, "data", "StudyS", "run1", "s1_R1.fastq.gz")) + mkpath(joinpath(tmp, "projects", "StudyS", "run1")) + try + @testset "Origin allow-list" begin + studies = "/api/v1/studies" + host = "Host" => "127.0.0.1:8080" + # No Origin (same-origin GET, curl): unchanged. + @test _ss_request("GET", studies, [host]).status == 200 + # The served origin, in both spellings, and the Vite dev server. + for o in ("http://127.0.0.1:8080", "http://localhost:8080", "http://localhost:5173", + "http://127.0.0.1:5173", "http://LOCALHOST:8080/") + r = _ss_request("GET", studies, [host, "Origin" => o]) + @test r.status == 200 + @test SV.HTTP.header(r, "Access-Control-Allow-Origin") == o + @test _ss_request("OPTIONS", studies, [host, "Origin" => o]).status == 204 + end + # Another loopback page: other port, other scheme, or no origin at all. + for o in ("http://localhost:3000", "http://127.0.0.1:9999", "https://localhost:8080", + "http://[::1]:8080", "null", "http://evil.example") + @test _ss_request("GET", studies, [host, "Origin" => o]).status == 403 + @test _ss_request("OPTIONS", studies, [host, "Origin" => o]).status == 403 + # A mutating request is refused before it reaches the route. + r = _ss_request("POST", "/api/v1/studies/StudyS/report?kind=figure&ext=.txt", + [host, "Origin" => o, "Content-Type" => "text/plain"], "x") + @test r.status == 403 + end + @test !isdir(joinpath(tmp, "projects", "StudyS", "report")) + # The allowed set follows the bound port. + SV._bound_port[] = 9090 + @test _ss_request("GET", studies, [host, "Origin" => "http://localhost:9090"]).status == 200 + @test _ss_request("GET", studies, [host, "Origin" => "http://localhost:8080"]).status == 403 + SV._bound_port[] = 8080 + # Extra origins come from JULIA_METAMANIFOLD_ALLOWED_ORIGINS. + withenv("JULIA_METAMANIFOLD_ALLOWED_ORIGINS" => " http://lab-box:8080 , http://localhost:5174/") do + @test _ss_request("GET", studies, [host, "Origin" => "http://lab-box:8080"]).status == 200 + @test _ss_request("GET", studies, [host, "Origin" => "http://localhost:5174"]).status == 200 + @test _ss_request("GET", studies, [host, "Origin" => "http://localhost:3000"]).status == 403 + end + # The Host check still applies first. + @test _ss_request("GET", studies, ["Host" => "evil.example:8080"]).status == 403 + end + + @testset "Oxygen docs and metrics are not served" begin + @test SV._SERVE_OPTIONS.docs === false + @test SV._SERVE_OPTIONS.metrics === false + # With a frontend build present, an unknown path falls through to the + # SPA catch-all and answers 200 with index.html. That is the app, not + # the docs, so accept exactly that body as well as a non-200 answer. + index = joinpath(SV._frontend_dir, "index.html") + spa = isfile(index) ? read(index) : nothing + for path in ("/docs", "/docs/schema", "/docs/swagger", "/docs/metrics") + r = _ss_served(path) + body = lowercase(String(copy(r.body))) + @test !occursin("openapi", body) + @test !occursin("swagger", body) + @test !occursin("redoc", body) + @test r.status != 200 || (spa !== nothing && r.body == spa) + end + # The served handler still answers the API. + @test _ss_served("/api/v1/studies").status == 200 + end + + @testset "Report uploads are served inert" begin + host = "Host" => "127.0.0.1:8080" + base = "/api/v1/studies/StudyS/report" + svg = "" + r = _ss_request("POST", "$base?kind=figure&title=X&ext=.svg", [host], svg) + @test r.status == 200 + item = JSON3.read(String(r.body)) + for path in ("$base/$(item.id)/file", "/files/StudyS/runs/report/$(item.file)") + f = _ss_request("GET", path, [host]) + @test f.status == 200 + @test String(f.body) == svg + @test SV.HTTP.header(f, "Content-Type") == "image/svg+xml" + @test SV.HTTP.header(f, "X-Content-Type-Options") == "nosniff" + @test SV.HTTP.header(f, "Content-Security-Policy") == + "default-src 'none'; style-src 'unsafe-inline'; sandbox" + end + # Pipeline output under /files/ is sniff-proof but keeps working inline. + write(joinpath(tmp, "projects", "StudyS", "run1", "plot.pdf"), "%PDF-1.4") + f = _ss_request("GET", "/files/StudyS/runs/run1/plot.pdf", [host]) + @test f.status == 200 + @test SV.HTTP.header(f, "X-Content-Type-Options") == "nosniff" + @test SV.HTTP.header(f, "Content-Security-Policy") == "" + end + finally + SV.ServerState._root[] = old_root + SV._bound_host[], SV._bound_port[] = old_host, old_port + rm(tmp; recursive=true, force=true) + end +end