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:
2026-06-12 00:58:01 +02:00
co-authored by Claude Fable 5
parent a919d79ce1
commit 9889afdc35
5 changed files with 117 additions and 4 deletions
+52 -3
View File
@@ -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',