Skip to content

[Security] LobsterAI HTML preview server follows in-root symlinks and discloses arbitrary local files #2288

Description

@YLChen-007

Advisory Details

Title: LobsterAI HTML preview server follows in-root symlinks and discloses arbitrary local files

Description:

Summary

LobsterAI's localhost HTML preview server tries to confine preview asset requests to the selected preview directory with a lexical path.resolve(...).startsWith(...) check, but it then serves the resulting path with normal symlink-following filesystem calls. If a previewed HTML artifact directory contains a child symlink that points outside the intended preview root, the preview server will return files from the external target to the preview page.

Details

I verified this against LobsterAI's real src/main/libs/htmlPreviewServer.ts implementation and the real loopback HTTP server started by createPreviewSession(). This is not a generic ../ traversal finding. The server correctly rejects encoded traversal such as ..%2Foutside%2Fcanary.txt with HTTP 403. The escape happens because the confinement check is lexical, not canonical.

The relevant logic is:

const resolvedPath = path.resolve(session.rootDir, relativePath);

if (!resolvedPath.startsWith(session.rootDir)) {
  res.writeHead(403);
  res.end('Forbidden');
  return;
}

streamFile(resolvedPath, res);

Once resolvedPath passes the prefix check, streamFile() uses fs.stat() and fs.createReadStream() on that path. Those calls follow symlinks, so a path that still looks in-root lexically can resolve to an external file on disk at open time:

fs.stat(filePath, (err, stat) => {
  ...
  const stream = fs.createReadStream(filePath);
  stream.pipe(res);
});

In the attached reproduction, the previewed HTML file lives under a temporary workspace/ directory. A sibling symlink workspace/escape -> ../outside/ is created before the preview request is made. The direct traversal control request returns 403, and the same escape/canary.txt child path returns 404 when the symlink is absent. After the symlink is present, the same child path returns 200 and serves the canary from the external outside/ directory.

I also checked the public release history before assigning the affected range. Release 2026.5.12 does not contain src/main/libs/htmlPreviewServer.ts at all. Release 2026.5.14 introduces the preview server with the same vulnerable lexical containment logic, and the highest public release 2026.6.15 still contains the bug. That release tag resolves to remote commit 5351d1ec650325b6a107a99704a1e5415de9097d, and all occurrence links below are pinned to that exact commit.

This reproduction uses the real preview server module and real HTTP requests over 127.0.0.1. A full Electron UI run was not required to confirm the defect, although in this workspace npm run electron:dev also failed earlier because the local host did not meet the repository's Node/Electron runtime requirements.

PoC

Prerequisites

  • A checkout of https://github.com/netease-youdao/LobsterAI
  • python3
  • node with --experimental-transform-types support; the repository itself requires Node >=24 <25
  • Install repository dependencies first so src/main/libs/htmlPreviewServer.ts and its imports can be executed from the source tree
  • Place the downloaded PoC files into llm-enhance/cve-finding/similar/Info_Leak/Advisory-GHSA-qcc4-p59m-p54m-html-preview-symlink-escape-exp/ inside the repository checkout so their relative paths resolve correctly

Reproduction Steps

  1. Download the harness from: preview_session_harness.mjs
  2. Download the verification driver from: verification_test.py
  3. Download the control driver from: control-normal-behavior.py
  4. Place the three files under llm-enhance/cve-finding/similar/Info_Leak/Advisory-GHSA-qcc4-p59m-p54m-html-preview-symlink-escape-exp/ in the LobsterAI checkout.
  5. From the repository root, run the verification case:
    python3 llm-enhance/cve-finding/similar/Info_Leak/Advisory-GHSA-qcc4-p59m-p54m-html-preview-symlink-escape-exp/verification_test.py
  6. Confirm the output shows:
    • encoded ../ traversal returns HTTP 403
    • the symlinked child request returns HTTP 200
    • the outside canary file is disclosed
  7. Run the matched control case:
    python3 llm-enhance/cve-finding/similar/Info_Leak/Advisory-GHSA-qcc4-p59m-p54m-html-preview-symlink-escape-exp/control-normal-behavior.py
  8. Confirm the control output shows:
    • encoded ../ traversal still returns HTTP 403
    • the same escape/canary.txt child path returns HTTP 404 when no symlink is present

Log of Evidence

Observed verification output:

[Verification Mode] Integration-Test
[Setup] Launching the real preview HTTP server through createPreviewSession() and exercising it over localhost.
[Liveness] Preview server bound to 127.0.0.1:46739
[Control] Encoded ../ traversal returned HTTP 403
[Exploit] Symlinked child path returned HTTP 200
[DEFECT-CONFIRMED-WITH-LIMITATIONS]
[Evidence] Canary leaked via http://127.0.0.1:46739/6b4464f1f02008e8dd41ba20452479c1/escape/canary.txt?token=ce3aef561201791e1b0e7978a79e030f68f28d0cf5a9a4b1

Observed control output:

[Verification Mode] Integration-Test
[Setup] Launching the same preview server without the symlink trigger input.
[Liveness] Preview server bound to 127.0.0.1:38599
[Control] Encoded ../ traversal returned HTTP 403
[Baseline] Same child path without symlink returned HTTP 404
[CONTROL-PASS]

Independent raw HTTP evidence:

HTTP/1.1 200 OK
Cache-Control: no-cache, no-store, must-revalidate
Access-Control-Allow-Origin: *
Content-Type: text/plain; charset=utf-8

lobster-canary-1781545169094-72f560e0e614

Impact

This is a local file disclosure issue in LobsterAI's HTML artifact preview path. An attacker-controlled or attacker-influenced file-based HTML artifact can cause the preview server to serve files outside the intended preview directory as long as the preview root contains an in-root symlink alias to an external location.

The impacted asset is any local file readable by the LobsterAI desktop process under the victim user's account, including source files, tokens stored in plain-text config files, local documents, and other workspace material outside the directory the user expected to preview. Because the disclosure happens through the preview origin itself, the previewed page can request those bytes as relative assets once the user opens the artifact.

Affected products

  • Ecosystem: npm
  • Package name: lobsterai
  • Affected versions: >= 2026.5.14, <= 2026.6.15
  • Patched versions:

Severity

  • Severity: Medium
  • Vector string: CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N

Weaknesses

  • CWE: CWE-59: Improper Link Resolution Before File Access ('Link Following')

Occurrences

Permalink Description
const resolvedPath = path.resolve(session.rootDir, relativePath);
// Path traversal protection
if (!resolvedPath.startsWith(session.rootDir)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
streamFile(resolvedPath, res);
handleRequest() resolves the attacker-influenced child path under session.rootDir and only enforces lexical prefix containment with startsWith, which misses symlink aliases that still retain the same path prefix.
function streamFile(filePath: string, res: http.ServerResponse): void {
fs.stat(filePath, (err, stat) => {
if (err || !stat.isFile()) {
res.writeHead(404);
res.end('Not Found');
return;
}
writePreviewHeaders(res, 200, {
'Content-Type': getMimeType(filePath),
'Content-Length': stat.size,
});
const stream = fs.createReadStream(filePath);
stream.pipe(res);
streamFile() uses fs.stat() and fs.createReadStream() on the already-approved path. Those filesystem calls follow the symlink and stream the external target file back to the preview client.
export async function createPreviewSession(filePath: string): Promise<{ sessionId: string; url: string }> {
const resolvedFilePath = path.resolve(filePath);
const stat = await fs.promises.stat(resolvedFilePath);
if (!stat.isFile()) {
throw new Error('Preview target is not a file');
}
const port = await startHtmlPreviewServer();
const sessionId = crypto.randomBytes(16).toString('hex');
const token = crypto.randomBytes(24).toString('hex');
const rootDir = path.dirname(resolvedFilePath) + path.sep;
const fileName = path.basename(resolvedFilePath);
sessions.set(sessionId, { rootDir, token, filePath: resolvedFilePath, kind: 'html' });
const url = `http://127.0.0.1:${port}/${sessionId}/${encodeURIComponent(fileName)}?token=${token}`;
createPreviewSession() anchors the trust boundary to path.dirname(resolvedFilePath) + path.sep and exposes the selected HTML file over the localhost preview server, making sibling asset requests reachable through the same session.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions