Skip to content

Commit e0c87b4

Browse files
ndemiancclaude
andcommitted
fix(ai): remote-only output must open nothing; document the real local bound (PR #35 review)
1. The SECURITY test allowed sniffPreviewUrl to return a localhost URL for the remote-only fixtures, so it was weaker than the sentence above it claimed — and it was permitting a real leak, not a theoretical one. sniffPort's first pattern matches ANY http(s) URL, so after refusing a remote host the fallback re-extracted the port out of that same refused address: "Local: http://evil.example.com:3000/" -> "http://localhost:3000" A preview conjured entirely out of a line we had deliberately ignored. The fallback now blanks remote URLs first, so remote-only output yields null. The assertion is strict equality against null, matching its stated contract. A bare port with no host ("running on port 3000") still works — that is the Express-boilerplate case the fallback exists for — and mixed Local/Network output still picks the local one. 2. The setting description understated the bound: it said localhost / 127.0.0.1, while the implementation also accepts the IPv6 loopback [::1] and treats the bind addresses 0.0.0.0 and [::] as local (rewriting them to localhost, which a browser can actually resolve). Now says so. Verified: 23 suites, 0 failures. Mutation-checked — restoring the raw-text fallback reproduces the leak above and fails the suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 81d7614 commit e0c87b4

3 files changed

Lines changed: 16 additions & 8 deletions

File tree

extensions/levelcode-ai/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@
439439
"levelcode.ai.preview.autoOpen": {
440440
"type": "boolean",
441441
"default": true,
442-
"markdownDescription": "When the agent starts a web server in the background, automatically open the site in LevelCode's built-in browser, beside the chat.\n\nOnly **local** addresses are ever opened (`localhost` / `127.0.0.1`), and each address opens at most once per sessionso closing the tab is respected. Your focus is never taken. Turn this off to open previews yourself with **Simple Browser: Show**."
442+
"markdownDescription": "When the agent starts a web server in the background, automatically open the site in LevelCode's built-in browser, beside the chat.\n\nOnly **local** addresses are ever opened `localhost`, `127.0.0.1`, or the IPv6 loopback `[::1]`. The bind addresses `0.0.0.0` and `[::]` count as local and are shown as `localhost`, since a browser can't resolve them. A remote URL printed by a dev script is ignored, never opened.\n\nEach address opens at most once per session, so closing the tab is respected, and your focus is never taken. Turn this off to open previews yourself with **Simple Browser: Show**."
443443
},
444444
"levelcode.ai.mcp.servers": {
445445
"type": "object",

extensions/levelcode-ai/test/verify.test.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ const REMOTE = [
3131
'http://127.0.0.1.evil.com:3000' // same trick with the loopback literal
3232
];
3333

34-
test('SECURITY: a remote url in command output is never opened', () => {
34+
test('SECURITY: remote-only output opens NOTHING', () => {
35+
// Strictly null, not "null or some localhost url". An earlier version of this assertion allowed a
36+
// localhost fallback, which quietly permitted the thing it was meant to forbid: the port would be
37+
// re-extracted from the refused remote address and we'd open localhost:<their port> — a preview
38+
// conjured entirely out of a line we ignored. If the fixture is remote-only, the answer is nothing.
3539
for (const line of REMOTE) {
36-
const got = sniffPreviewUrl(line);
37-
assert.ok(
38-
got === null || /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::|\/|$)/.test(got),
39-
'opened a non-local address from ' + JSON.stringify(line) + ' -> ' + JSON.stringify(got)
40-
);
40+
assert.strictEqual(sniffPreviewUrl(line), null,
41+
'remote-only output must open nothing, got ' + JSON.stringify(sniffPreviewUrl(line)) +
42+
' from ' + JSON.stringify(line));
4143
}
4244
});
4345

extensions/levelcode-ai/verify.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,13 @@ function sniffPreviewUrl(text) {
124124
const host = /^(?:0\.0\.0\.0|\[::\])$/i.test(m[2]) ? 'localhost' : m[2];
125125
return m[1].toLowerCase() + '://' + host + ':' + m[3] + (m[4] || '');
126126
}
127-
const port = sniffPort(s);
127+
// Nothing local was printed as a URL. Before falling back to a bare port, blank out any REMOTE one:
128+
// sniffPort's first pattern matches any http(s) URL, so it would re-extract the port out of the very
129+
// address we just refused and we'd open localhost:<their port> — a preview inferred entirely from a
130+
// line we deliberately ignored. A port with no host attached ("running on port 3000") still counts.
131+
const localOnly = s.replace(/(https?):\/\/([a-z0-9.\-]+|\[[0-9a-f:]*\]):(\d{2,5})(\/[^\s"'<>)\]]*)?/gi,
132+
(full, _scheme, host) => (LOCAL_HOSTS.test(host) ? full : ' '));
133+
const port = sniffPort(localOnly);
128134
return port ? 'http://localhost:' + port : null;
129135
}
130136

0 commit comments

Comments
 (0)