require a bearer token on the MCP HTTP server (T-362)
D-71's threat model — another user on the same host must not drive my IDE — was enforced with 0600 on the unix socket and then bypassed wholesale by the unauthenticated localhost SSE port, which since D-86 serves every clide verb as a tool. The server now mints 32 bytes of CSPRNG token per start, publishes it via the /ide discovery lock file's authToken slot (the field Claude Code's client reads), chmods the lock to 0600, and rejects any request that doesn't present the token in x-claude-code-ide-authorization with 401. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3992,3 +3992,5 @@ Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork s
|
||||
UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority.
|
||||
|
||||
T-361 (done, 2026-06-12) added the session-level building blocks this ticket can reuse: StreamJsonSession now watches the process exit code (SessionEnd with stderr tail, replay-latest via session.end) and the pane surfaces ''claude exited (code N) — /clear to restart''. A resume that dies at spawn now surfaces instead of hanging silently; what remains here is the timeout/fallback for a resume that starts but never produces the init event, and resume-decided-by-content.', NULL, '2026-06-11 22:53:52', '2026-06-11 22:53:52', '2026-06-11 22:53:52', NULL, '5a99913696126412ccc7b18f75d3ec15', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:54:18', '2026-06-11 22:54:18', '2026-06-11 22:54:18', NULL, '2cadeabff0a0a89bbcc06db35dfa2f15', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:57:42', '2026-06-11 22:57:42', '2026-06-11 22:57:42', NULL, 'cb060646e7e967d607dc013a75ec0a28', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -4060,3 +4060,9 @@ Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork s
|
||||
UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority.
|
||||
|
||||
T-361 (done, 2026-06-12) added the session-level building blocks this ticket can reuse: StreamJsonSession now watches the process exit code (SessionEnd with stderr tail, replay-latest via session.end) and the pane surfaces ''claude exited (code N) — /clear to restart''. A resume that dies at spawn now surfaces instead of hanging silently; what remains here is the timeout/fallback for a resume that starts but never produces the init event, and resume-decided-by-content.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-06-08 13:39:51', '2026-06-11 22:53:52', NULL, '06cc0a2e629ee7a192d24286f58af27f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'MCP HTTP server exposes the full dispatcher with zero auth', 'lib/src/ipc/mcp_server.dart:138-195, started unconditionally at boot (lib/main.dart:174-180). D-71 threat model (another user on the same host must not drive my IDE) is enforced with 0600 on the unix socket — then bypassed wholesale by an unauthenticated localhost HTTP port that, since D-86, serves every clide verb as a tool.
|
||||
|
||||
Fix: generate a token in the lock file (Claude Code /ide lock format has a slot for it) and require the auth header on every request. Tests: request without token is rejected; token round-trips via the lock file.', 'in_progress', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:13', '2026-06-11 22:54:18', NULL, '320e3e3eeb46d8a70e5ecfd5c903563d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBN5F0F8SDF15P21DNKT1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'MCP HTTP server exposes the full dispatcher with zero auth', 'lib/src/ipc/mcp_server.dart:138-195, started unconditionally at boot (lib/main.dart:174-180). D-71 threat model (another user on the same host must not drive my IDE) is enforced with 0600 on the unix socket — then bypassed wholesale by an unauthenticated localhost HTTP port that, since D-86, serves every clide verb as a tool.
|
||||
|
||||
Fix: generate a token in the lock file (Claude Code /ide lock format has a slot for it) and require the auth header on every request. Tests: request without token is rejected; token round-trips via the lock file.', 'done', 'critical', NULL, NULL, NULL, '2026-06-11 21:55:13', '2026-06-11 22:57:42', NULL, '14de71ed19e921a370001097887a7e6f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -92,6 +92,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Security
|
||||
|
||||
- **The MCP HTTP server now requires a per-start auth token.** The localhost
|
||||
SSE port served the entire clide command surface unauthenticated,
|
||||
bypassing the unix socket's 0600 gate; requests must now present the
|
||||
token published in the 0600 `/ide` lock file. (T-362)
|
||||
|
||||
- **`editor.open` / `editor.save` are now workspace-confined.** Both verbs
|
||||
accepted absolute paths and `..` traversal verbatim — an unconfined read
|
||||
and write primitive over IPC. They now pass the same path-safety guard as
|
||||
|
||||
@@ -23,6 +23,7 @@ library;
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
@@ -33,6 +34,12 @@ import 'package:clide/src/ipc/envelope.dart';
|
||||
/// separate `/ide` minimum (D-68).
|
||||
const String _clideToolPrefix = 'mcp__clide__';
|
||||
|
||||
/// Auth header Claude Code's `/ide` client sends, populated from the lock
|
||||
/// file's `authToken`. Every request must carry it (T-362): the unix socket
|
||||
/// is gated by 0600 per D-71, and an unauthenticated localhost HTTP port
|
||||
/// would bypass that gate wholesale.
|
||||
const String kMcpAuthHeader = 'x-claude-code-ide-authorization';
|
||||
|
||||
/// One connected SSE client. Each session has its own response
|
||||
/// stream; POST /messages routes back to the right one via the
|
||||
/// `sessionId` query param.
|
||||
@@ -89,6 +96,7 @@ class McpServer {
|
||||
HttpServer? _http;
|
||||
String? _lockFile;
|
||||
int? _port;
|
||||
String? _authToken;
|
||||
final Map<String, _McpSession> _sessions = {};
|
||||
int _sessionCounter = 0;
|
||||
|
||||
@@ -96,11 +104,16 @@ class McpServer {
|
||||
int? get port => _port;
|
||||
String? get lockFilePath => _lockFile;
|
||||
|
||||
/// The per-start bearer token clients must present in [kMcpAuthHeader].
|
||||
/// Published to legitimate clients via the 0600 lock file only.
|
||||
String? get authToken => _authToken;
|
||||
|
||||
Future<void> start() async {
|
||||
if (isRunning) return;
|
||||
final server = await HttpServer.bind(bindHost, bindPort);
|
||||
_http = server;
|
||||
_port = server.port;
|
||||
_authToken = _generateToken();
|
||||
_lockFile = await _writeDiscoveryFile();
|
||||
server.listen(
|
||||
_route,
|
||||
@@ -136,6 +149,13 @@ class McpServer {
|
||||
// -- routing --------------------------------------------------------------
|
||||
|
||||
Future<void> _route(HttpRequest req) async {
|
||||
// Token gate first, on every path (T-362). Without it, any local
|
||||
// process could drive the entire dispatcher D-71's 0600 socket guards.
|
||||
if (req.headers.value(kMcpAuthHeader) != _authToken) {
|
||||
req.response.statusCode = HttpStatus.unauthorized;
|
||||
await req.response.close();
|
||||
return;
|
||||
}
|
||||
final path = req.uri.path;
|
||||
if (path == '/sse' && req.method == 'GET') {
|
||||
await _openSseStream(req);
|
||||
@@ -327,8 +347,39 @@ class McpServer {
|
||||
dirHandle.createSync(recursive: true);
|
||||
}
|
||||
final path = '$dir/$pid.lock';
|
||||
final body = jsonEncode({'pid': pid, 'workspace': workspaceRoot, 'transport': 'sse', 'url': 'http://$bindHost:$_port/sse'});
|
||||
final body = jsonEncode({
|
||||
'pid': pid,
|
||||
'workspace': workspaceRoot,
|
||||
'transport': 'sse',
|
||||
'url': 'http://$bindHost:$_port/sse',
|
||||
// Claude Code's /ide lock format carries the bearer token here; the
|
||||
// 0600 below is what scopes it to this user (T-362).
|
||||
'authToken': _authToken,
|
||||
});
|
||||
File(path).writeAsStringSync(body);
|
||||
try {
|
||||
await _chmod(path, '600');
|
||||
} catch (e) {
|
||||
// Not fatal like the socket's chmod (D-71): the lock lives under
|
||||
// ~/.claude which the home-dir perms usually already protect. But say so.
|
||||
log.warn('mcp', 'chmod 600 on $path failed: $e — the auth token may be readable by other local users');
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/// 32 bytes of CSPRNG entropy, base64url — the per-start bearer token.
|
||||
static String _generateToken() {
|
||||
final rng = Random.secure();
|
||||
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
|
||||
return base64UrlEncode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
/// `chmod` via `chmod(1)` — dart:io doesn't expose mode bits (same
|
||||
/// approach as the unix-socket server, D-71).
|
||||
static Future<void> _chmod(String path, String octal) async {
|
||||
final r = await Process.run('chmod', [octal, path]);
|
||||
if (r.exitCode != 0) {
|
||||
throw ProcessException('chmod', [octal, path], r.stderr.toString(), r.exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,12 @@ void main() {
|
||||
if (discoveryDir.existsSync()) discoveryDir.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
Future<HttpClientResponse> openSse() async {
|
||||
Future<HttpClientResponse> openSse({String? token}) async {
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final req = await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/sse'));
|
||||
final t = token ?? server.authToken;
|
||||
if (t != null) req.headers.set(kMcpAuthHeader, t);
|
||||
return req.close();
|
||||
}
|
||||
|
||||
@@ -64,6 +66,7 @@ void main() {
|
||||
addTearDown(client.close);
|
||||
final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=$sessionId'));
|
||||
req.headers.contentType = ContentType.json;
|
||||
req.headers.set(kMcpAuthHeader, server.authToken!);
|
||||
req.write(jsonEncode(body));
|
||||
final resp = await req.close();
|
||||
expect(resp.statusCode, HttpStatus.accepted);
|
||||
@@ -93,11 +96,49 @@ void main() {
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final req = await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/no-such-thing'));
|
||||
req.headers.set(kMcpAuthHeader, server.authToken!);
|
||||
final resp = await req.close();
|
||||
expect(resp.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
});
|
||||
|
||||
// T-362: D-71's "another user on this host must not drive my IDE" is
|
||||
// enforced with 0600 on the unix socket — the HTTP port must not bypass it.
|
||||
group('McpServer (T-362) auth token', () {
|
||||
test('the lock file publishes the auth token, mode 600', () async {
|
||||
final lock = File(server.lockFilePath!);
|
||||
final payload = jsonDecode(lock.readAsStringSync()) as Map<String, Object?>;
|
||||
expect(payload['authToken'], server.authToken);
|
||||
expect((server.authToken ?? '').length, greaterThanOrEqualTo(32));
|
||||
final mode = lock.statSync().mode & 0xFFF;
|
||||
expect(mode, 0x180, reason: 'lock file must be 0600 — it carries the token');
|
||||
});
|
||||
|
||||
test('a request without the token is rejected with 401', () async {
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final sse = await (await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/sse'))).close();
|
||||
expect(sse.statusCode, HttpStatus.unauthorized);
|
||||
|
||||
final post = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=s0'));
|
||||
post.write('{"jsonrpc":"2.0","id":1,"method":"initialize"}');
|
||||
final resp = await post.close();
|
||||
expect(resp.statusCode, HttpStatus.unauthorized);
|
||||
});
|
||||
|
||||
test('a request with a wrong token is rejected with 401', () async {
|
||||
final resp = await openSse(token: 'not-the-token');
|
||||
expect(resp.statusCode, HttpStatus.unauthorized);
|
||||
});
|
||||
|
||||
test('the token rotates per start', () async {
|
||||
final first = server.authToken;
|
||||
await server.stop();
|
||||
await server.start();
|
||||
expect(server.authToken, isNot(first));
|
||||
});
|
||||
});
|
||||
|
||||
group('McpServer (T-130) JSON-RPC', () {
|
||||
test('SSE opens with an endpoint event carrying the session id', () async {
|
||||
final (sessionId, events) = await connectAndCaptureEndpoint();
|
||||
@@ -172,6 +213,7 @@ void main() {
|
||||
addTearDown(client.close);
|
||||
final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=ghost'));
|
||||
req.headers.contentType = ContentType.json;
|
||||
req.headers.set(kMcpAuthHeader, server.authToken!);
|
||||
req.write('{"jsonrpc":"2.0","id":1,"method":"initialize"}');
|
||||
final resp = await req.close();
|
||||
expect(resp.statusCode, HttpStatus.notFound);
|
||||
@@ -183,6 +225,7 @@ void main() {
|
||||
addTearDown(client.close);
|
||||
final req = await client.postUrl(Uri.parse('http://127.0.0.1:${server.port}/messages?sessionId=$sessionId'));
|
||||
req.headers.contentType = ContentType.json;
|
||||
req.headers.set(kMcpAuthHeader, server.authToken!);
|
||||
req.write('{not json');
|
||||
final resp = await req.close();
|
||||
expect(resp.statusCode, HttpStatus.badRequest);
|
||||
@@ -224,7 +267,9 @@ void main() {
|
||||
Future<(String, Stream<String>)> connect() async {
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final resp = await (await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'))).close();
|
||||
final sseReq = await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'));
|
||||
sseReq.headers.set(kMcpAuthHeader, srv.authToken!);
|
||||
final resp = await sseReq.close();
|
||||
final dataLines = resp
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
@@ -246,6 +291,7 @@ void main() {
|
||||
addTearDown(client.close);
|
||||
final req = await client.postUrl(Uri.parse('http://127.0.0.1:${srv.port}/messages?sessionId=$sid'));
|
||||
req.headers.contentType = ContentType.json;
|
||||
req.headers.set(kMcpAuthHeader, srv.authToken!);
|
||||
req.write(jsonEncode(body));
|
||||
final resp = await req.close();
|
||||
expect(resp.statusCode, HttpStatus.accepted);
|
||||
@@ -306,7 +352,9 @@ void main() {
|
||||
});
|
||||
final client = HttpClient();
|
||||
addTearDown(client.close);
|
||||
final resp = await (await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'))).close();
|
||||
final sseReq = await client.getUrl(Uri.parse('http://127.0.0.1:${srv.port}/sse'));
|
||||
sseReq.headers.set(kMcpAuthHeader, srv.authToken!);
|
||||
final resp = await sseReq.close();
|
||||
final data = resp
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
@@ -323,6 +371,7 @@ void main() {
|
||||
final replyFuture = data.firstWhere((s) => s.contains('"id":13'));
|
||||
final post = await client.postUrl(Uri.parse('http://127.0.0.1:${srv.port}/messages?sessionId=$sid'));
|
||||
post.headers.contentType = ContentType.json;
|
||||
post.headers.set(kMcpAuthHeader, srv.authToken!);
|
||||
post.write(
|
||||
jsonEncode({
|
||||
'jsonrpc': '2.0',
|
||||
|
||||
Reference in New Issue
Block a user