Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b21d005
Add a CLR linear-model method for differential abundance
hyperpolymath Oct 2, 2026
3acf607
clr_lm: filter by prevalence before cmultRepl, and test with Welch
hyperpolymath Oct 5, 2026
c548451
Rename the clr_lm method to clr_welch
hyperpolymath Oct 5, 2026
657120c
Test clr_welch when zCompositions is missing
hyperpolymath Oct 6, 2026
b86d59f
Run the zCompositions-absent test in CI instead of skipping it
hyperpolymath Oct 6, 2026
7799de8
Test the cmultRepl output check directly
hyperpolymath Oct 7, 2026
3f9a5bc
proofs(agda): Benjamini-Hochberg adjustment over exact rationals
hyperpolymath Oct 2, 2026
1d8572f
proofs(agda): TSS proportions and RLE median positivity over exact ra…
hyperpolymath Oct 2, 2026
b3e871d
proofs: axiom audit for the Agda tree; align headers with D1's Julia
hyperpolymath Oct 2, 2026
a3ccd2e
proofs: ignore Agda interface files
hyperpolymath Oct 2, 2026
6df0e56
ci: type-check the Agda proofs; docs: map each theorem to its Julia code
hyperpolymath Oct 2, 2026
aacf5d1
docs: keep the theorem-to-code map with the proofs
hyperpolymath Oct 2, 2026
af75c8c
fix(differential): refuse complete separation instead of reporting p≈1
hyperpolymath Oct 5, 2026
c860980
fix: separate a significance test that did not run from a null result
hyperpolymath Sep 22, 2026
f71fa03
Test that an omnibus statistic R cannot compute is a failed test
hyperpolymath Oct 6, 2026
c4b2efc
fix(server): log an unreadable config at warn, not error
hyperpolymath Sep 21, 2026
144d0c1
Test that an unreadable primers.yml is logged as a warning
hyperpolymath Oct 6, 2026
d0b31f4
fix(ci): give the required status check a version-stable name
hyperpolymath Sep 21, 2026
5b45d09
fix(ci): drop the 1x1 matrix so the check name is actually stable
hyperpolymath Sep 21, 2026
f9cf729
docs(ci): name the fork PR that measured the matrix-suffixed check name
hyperpolymath Oct 1, 2026
9d888f1
docs: the Julia pin is repeated in the CI workflow, which has no matrix
hyperpolymath Oct 5, 2026
a37a642
Gate file-name config values and remote keys; guard pipeline tables o…
hyperpolymath Oct 6, 2026
578915c
Test that a remote host without a staging_dir is refused
hyperpolymath Oct 6, 2026
592ddd7
fix(permanova): test each term, report permutations, refuse untestabl…
hyperpolymath Oct 14, 2026
bcf52af
Clear stale analysis results and surface request failures in the UI
hyperpolymath Oct 5, 2026
8dda16d
Harden server origin checks, disable Oxygen docs, serve uploads inert
hyperpolymath Oct 5, 2026
e877e13
Accept the SPA index for /docs paths when a frontend build is present
hyperpolymath Oct 8, 2026
2775faf
docs: describe split deployments and the origins the server must be t…
hyperpolymath Oct 14, 2026
678cacd
chore(sync): merge upstream main (2775faf) into the fork — resolve th…
hyperpolymath Oct 14, 2026
17d5289
Merge branch 'main' into chore/sync-upstream-2775faf
hyperpolymath Oct 15, 2026
5a60887
docs(server): clarify origin guards, file middleware, and startup beh…
coderabbitai[bot] Oct 15, 2026
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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:<port>`
and `http://127.0.0.1:<port>`), 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:
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/DifferentialPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
9 changes: 8 additions & 1 deletion src/server/routes/report.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> 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
Expand All @@ -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

Expand Down
99 changes: 88 additions & 11 deletions src/server/server.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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[])
Expand All @@ -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",
Expand All @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

"""
Expand Down
1 change: 1 addition & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
128 changes: 128 additions & 0 deletions test/unit/test_server_security.jl
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
@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 = "<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></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
Loading