Skip to content

Security Report: Multiple vulnerabilities in Flame #494

Description

@Lavender-exe

Reported by: Whispergate Security Research
Date: 2026-08-17
Tested version: Latest master (cloned 2026-08-17)
Disclosure policy: 90-day coordinated disclosure


Hi there. Whispergate performed a security review of Flame and found eight exploitable vulnerabilities ranging from critical to medium severity. This report covers all of them in one place with reproduction steps and suggested fixes. No issues were filed publicly before reaching out - this is the initial disclosure.

Findings Overview

# Finding Severity CVSS CWE
1 No rate limiting on login endpoint Critical 9.8 CWE-307
2 Plaintext password storage with loose equality Critical 9.1 CWE-256
3 Stored XSS via SVG file upload High 8.1 CWE-79
4 Unauthenticated access to all GET endpoints High 7.5 CWE-306
5 Client-controlled JWT token lifetime High 7.2 CWE-613
6 SSRF via Docker host configuration injection Medium 6.5 CWE-918
7 CSS injection for data exfiltration Medium 5.4 CWE-94
8 Mass assignment in Sequelize operations Medium 4.3 CWE-915

1. No rate limiting on login endpoint

The login endpoint at POST /api/auth/ accepts unlimited authentication attempts with no rate limiting, lockout, or backoff.

Reproduce:

# This can run thousands of times with no throttling
for pw in admin password flame 12345 changeme change_me; do
  curl -s -o /dev/null -w "$pw: %{http_code}\n" \
    -X POST http://localhost:5005/api/auth/ \
    -H "Content-Type: application/json" \
    -d "{\"password\":\"$pw\",\"duration\":\"1h\"}"
done

Fix: Add express-rate-limit to the auth route.

const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 });
router.post('/', loginLimiter, login);

2. Plaintext password storage with loose equality

In controllers/auth/login.js, the password is compared against the raw PASSWORD environment variable using loose equality:

const isMatch = process.env.PASSWORD == password;

The password is never hashed. Anyone with access to the container environment, docker inspect, or a .env file can read it in clear text.

Fix: Hash on first setup with bcrypt, compare with bcrypt.compare(), and use strict equality (===).


3. Stored XSS via SVG file upload

The multer middleware in middleware/multer.js allows SVG uploads (svg and svg+xml in the MIME type list). Uploaded files are served statically from /uploads/ with no authentication and no sanitization. An SVG with embedded JavaScript executes in any visitor's browser.

Reproduce:

  1. Create evil.svg:
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.cookie)">
  <text x="10" y="20">XSS</text>
</svg>
  1. Upload it as an app icon via the API or UI.
  2. Visit http://localhost:5005/uploads/<timestamp>--evil.svg - the script fires.

Fix: Remove SVG from allowed MIME types, or sanitize with DOMPurify. Serve uploads with Content-Disposition: attachment.


4. Unauthenticated access to all GET endpoints

All GET API endpoints return data without requiring authentication. The auth middleware sets req.isAuthenticated but never blocks requests. The requireAuth middleware is only applied to mutating endpoints.

Reproduce:

curl http://localhost:5005/api/config    # returns Docker host, coordinates, preferences
curl http://localhost:5005/api/apps      # returns all configured apps and URLs
curl http://localhost:5005/api/queries   # returns search engine templates

These all return full JSON responses with no Authorization-Flame header.

Fix: Apply requireAuth to all GET route handlers, or use a deny-by-default model.


5. Client-controlled JWT token lifetime

The login controller extracts a duration parameter from the request body and passes it to signToken() without validation. An authenticated user can request a token valid for any duration.

Reproduce:

curl -X POST http://localhost:5005/api/auth/ \
  -H "Content-Type: application/json" \
  -d '{"password":"change_me","duration":"999y"}'

The returned JWT has an exp claim set roughly 999 years in the future. The application has no token revocation mechanism, so this token is permanent.

Fix: Remove duration from user input. Set a fixed server-side maximum:

const token = signToken('24h');

6. SSRF via Docker host configuration injection

The PUT /api/config endpoint merges the full request body into the configuration with no allowlist. An authenticated attacker can set dockerHost to any hostname. The Docker integration controller then makes HTTP requests to that host via axios:

axios.get(`http://${host}/containers/json?...`)

Reproduce:

# Point dockerHost at the cloud metadata service
curl -X PUT http://localhost:5005/api/config \
  -H "Authorization-Flame: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"dockerHost":"169.254.169.254"}'

# Trigger the request
curl http://localhost:5005/api/apps/docker

Fix: Allowlist modifiable config keys. Validate dockerHost against localhost or private IP ranges only.


7. CSS injection for data exfiltration

The PUT /api/config/0/css endpoint writes user-supplied CSS directly to public/flame.css without sanitization. This file is loaded by every visitor. An authenticated attacker can inject CSS attribute selectors with url() to exfiltrate form data, or use @import to load external resources.

Fix: Strip url(), @import, expression(), and -moz-binding from CSS input. Consider validating against a safe CSS subset with a parser.


8. Mass assignment in Sequelize operations

Multiple controllers spread req.body directly into Sequelize calls:

// controllers/apps/createApp.js
App.create