Fremde Laufzeiten: Claude Code und Befehl in der Sandbox, Egress-Proxy, Vorschlag statt Einsatz (#712)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7d6c2c9495
commit
367ea387f6
@@ -0,0 +1,255 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Sandbox;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// #712/#655: The only way out of a sandbox — the list, the address check, the proxy and
|
||||
/// the relay the launcher runs inside. Unix sockets work on every platform .NET runs on,
|
||||
/// so this runs everywhere; that the sandbox has no other way out, <see cref="SandboxEscapeTests"/> tries.
|
||||
/// </summary>
|
||||
public sealed class EgressTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "ce-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
public EgressTests() => Directory.CreateDirectory(_dir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); }
|
||||
catch { /* cleanup is secondary */ }
|
||||
}
|
||||
|
||||
// ═══════════════════ the list ═══════════════════
|
||||
|
||||
[Theory]
|
||||
[InlineData("api.anthropic.com", 443, true)]
|
||||
[InlineData("API.Anthropic.com.", 443, true)]
|
||||
[InlineData("api.anthropic.com", 80, false)]
|
||||
[InlineData("evil.api.anthropic.com", 443, false)]
|
||||
[InlineData("api.anthropic.com.evil.net", 443, false)]
|
||||
[InlineData("files.example.org", 443, true)]
|
||||
[InlineData("example.org", 443, false)]
|
||||
[InlineData("a.b.example.org", 443, true)]
|
||||
[InlineData("git.example.net", 8443, true)]
|
||||
[InlineData("git.example.net", 443, false)]
|
||||
public void Only_listed_hosts_and_ports_pass(string host, int port, bool allowed)
|
||||
=> EgressRules.Parse(["api.anthropic.com", "*.example.org", "git.example.net:8443"]).Allows(host, port).ShouldBe(allowed);
|
||||
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1")]
|
||||
[InlineData("[::1]")]
|
||||
[InlineData("93.184.216.34")]
|
||||
public void An_address_literal_is_never_on_the_list(string host)
|
||||
=> EgressRules.Parse(["*.example.org", "93.184.216.34"]).Allows(host, 443).ShouldBeFalse();
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://example.org")]
|
||||
[InlineData("*")]
|
||||
[InlineData("*.")]
|
||||
public void What_is_not_a_host_is_refused_when_saved(string entry)
|
||||
=> Should.Throw<ArgumentException>(() => EgressRules.Parse([entry]));
|
||||
|
||||
// ═══════════════════ public addresses ═══════════════════
|
||||
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1")]
|
||||
[InlineData("10.1.2.3")]
|
||||
[InlineData("172.16.0.1")]
|
||||
[InlineData("192.168.40.48")]
|
||||
[InlineData("169.254.169.254")] // cloud metadata
|
||||
[InlineData("100.64.0.1")]
|
||||
[InlineData("0.0.0.0")]
|
||||
[InlineData("224.0.0.1")]
|
||||
[InlineData("::1")]
|
||||
[InlineData("fe80::1")]
|
||||
[InlineData("fd00::1")]
|
||||
[InlineData("::ffff:127.0.0.1")]
|
||||
[InlineData("2002:7f00:1::1")] // 6to4 around 127.0.0.1
|
||||
[InlineData("64:ff9b::a00:1")] // NAT64 around 10.0.0.1
|
||||
public void Local_and_private_addresses_are_not_public(string address)
|
||||
=> EgressProxy.IsPublic(IPAddress.Parse(address)).ShouldBeFalse();
|
||||
|
||||
[Theory]
|
||||
[InlineData("160.79.104.10")]
|
||||
[InlineData("8.8.8.8")]
|
||||
[InlineData("2607:6bc0::10")]
|
||||
public void Addresses_on_the_internet_are(string address)
|
||||
=> EgressProxy.IsPublic(IPAddress.Parse(address)).ShouldBeTrue();
|
||||
|
||||
// ═══════════════════ the proxy ═══════════════════
|
||||
|
||||
private static Task<IPAddress[]> Loopback(string host, CancellationToken ct) => Task.FromResult(new[] { IPAddress.Loopback });
|
||||
|
||||
/// <summary>A server that answers every line with "echo: " and the line.</summary>
|
||||
private static (TcpListener Listener, int Port) EchoServer()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using (client)
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
var reader = new StreamReader(stream);
|
||||
while (await reader.ReadLineAsync() is { } line)
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes("echo: " + line + "\n"));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception) { /* stopped */ }
|
||||
});
|
||||
return (listener, ((IPEndPoint)listener.LocalEndpoint).Port);
|
||||
}
|
||||
|
||||
private static async Task<(Stream Stream, string Head)> ConnectAsync(Stream stream, string target)
|
||||
{
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes($"CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n"));
|
||||
var head = new StringBuilder();
|
||||
var one = new byte[1];
|
||||
while (!head.ToString().EndsWith("\r\n\r\n", StringComparison.Ordinal) && await stream.ReadAsync(one) == 1)
|
||||
head.Append((char)one[0]);
|
||||
return (stream, head.ToString());
|
||||
}
|
||||
|
||||
private async Task<(Socket Socket, Stream Stream)> OpenAsync(string socketPath)
|
||||
{
|
||||
var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
|
||||
await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath));
|
||||
return (socket, new NetworkStream(socket, ownsSocket: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_listed_host_gets_a_tunnel()
|
||||
{
|
||||
var (server, port) = EchoServer();
|
||||
var path = Path.Combine(_dir, "e.sock");
|
||||
using var proxy = EgressProxy.Start(path, [$"service.test:{port}"], NullLogger.Instance, Loopback, _ => true);
|
||||
|
||||
var (socket, stream) = await OpenAsync(path);
|
||||
using (socket)
|
||||
{
|
||||
var (_, head) = await ConnectAsync(stream, $"service.test:{port}");
|
||||
head.ShouldStartWith("HTTP/1.1 200");
|
||||
|
||||
await stream.WriteAsync("hello\n"u8.ToArray());
|
||||
var reader = new StreamReader(stream);
|
||||
(await reader.ReadLineAsync()).ShouldBe("echo: hello");
|
||||
}
|
||||
server.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_host_that_is_not_listed_is_refused()
|
||||
{
|
||||
var path = Path.Combine(_dir, "e.sock");
|
||||
using var proxy = EgressProxy.Start(path, ["api.anthropic.com"], NullLogger.Instance, Loopback, _ => true);
|
||||
|
||||
var (socket, stream) = await OpenAsync(path);
|
||||
using (socket)
|
||||
{
|
||||
var (_, head) = await ConnectAsync(stream, "evil.example:443");
|
||||
head.ShouldStartWith("HTTP/1.1 403");
|
||||
}
|
||||
proxy.Refusals.ShouldContain(r => r.StartsWith("evil.example:443"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_listed_name_that_resolves_to_this_machine_is_refused()
|
||||
{
|
||||
// DNS rebinding: the name is on the list, the address is loopback — the instance's web port.
|
||||
var path = Path.Combine(_dir, "e.sock");
|
||||
using var proxy = EgressProxy.Start(path, ["api.anthropic.com"], NullLogger.Instance, Loopback);
|
||||
|
||||
var (socket, stream) = await OpenAsync(path);
|
||||
using (socket)
|
||||
{
|
||||
var (_, head) = await ConnectAsync(stream, "api.anthropic.com:443");
|
||||
head.ShouldStartWith("HTTP/1.1 403");
|
||||
}
|
||||
proxy.Refusals.ShouldContain(r => r.Contains("non-public"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plain_http_is_not_proxied()
|
||||
{
|
||||
var path = Path.Combine(_dir, "e.sock");
|
||||
using var proxy = EgressProxy.Start(path, ["example.org"], NullLogger.Instance, Loopback, _ => true);
|
||||
|
||||
var (socket, stream) = await OpenAsync(path);
|
||||
using (socket)
|
||||
{
|
||||
await stream.WriteAsync("GET http://example.org/ HTTP/1.1\r\nHost: example.org\r\n\r\n"u8.ToArray());
|
||||
var answer = await new StreamReader(stream).ReadLineAsync();
|
||||
answer.ShouldStartWith("HTTP/1.1 405");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════ the launcher ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task The_relay_carries_the_sandbox_port_to_the_proxy()
|
||||
{
|
||||
var (server, port) = EchoServer();
|
||||
var path = Path.Combine(_dir, "e.sock");
|
||||
using var proxy = EgressProxy.Start(path, [$"service.test:{port}"], NullLogger.Instance, Loopback, _ => true);
|
||||
|
||||
var relayListener = new TcpListener(IPAddress.Loopback, 0);
|
||||
relayListener.Start();
|
||||
var relayPort = ((IPEndPoint)relayListener.LocalEndpoint).Port;
|
||||
using var stop = new CancellationTokenSource();
|
||||
var relay = SandboxLauncher.RelayAsync(relayListener, path, stop.Token);
|
||||
|
||||
using (var client = new TcpClient())
|
||||
{
|
||||
await client.ConnectAsync(IPAddress.Loopback, relayPort);
|
||||
var (stream, head) = await ConnectAsync(client.GetStream(), $"service.test:{port}");
|
||||
head.ShouldStartWith("HTTP/1.1 200");
|
||||
await stream.WriteAsync("through\n"u8.ToArray());
|
||||
(await new StreamReader(stream).ReadLineAsync()).ShouldBe("echo: through");
|
||||
}
|
||||
|
||||
stop.Cancel();
|
||||
await relay;
|
||||
server.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_launcher_reads_its_options_and_leaves_the_program_its_own()
|
||||
{
|
||||
var options = SandboxLauncher.Parse(["--sandbox-launch", "--egress", "/run/clawd/egress.sock", "--secrets", "/run/clawd/env.json",
|
||||
"--", "/usr/bin/claude", "-p", "--egress", "x"]);
|
||||
|
||||
options.ShouldNotBeNull();
|
||||
options.EgressSocket.ShouldBe("/run/clawd/egress.sock");
|
||||
options.SecretFile.ShouldBe("/run/clawd/env.json");
|
||||
options.Program.ShouldBe("/usr/bin/claude");
|
||||
options.Arguments.ShouldBe(["-p", "--egress", "x"]);
|
||||
|
||||
SandboxLauncher.Parse(["--sandbox-launch", "--unknown", "--", "x"]).ShouldBeNull();
|
||||
SandboxLauncher.Parse(["--sandbox-launch", "--egress", "s"]).ShouldBeNull("no program");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_secret_file_is_readable_only_by_its_owner()
|
||||
{
|
||||
var file = Path.Combine(_dir, "env.json");
|
||||
await SandboxLauncher.WriteSecretFileAsync(file, new Dictionary<string, string> { ["ANTHROPIC_API_KEY"] = "sk-test" }, CancellationToken.None);
|
||||
|
||||
(await File.ReadAllTextAsync(file)).ShouldContain("sk-test");
|
||||
if (!OperatingSystem.IsWindows())
|
||||
File.GetUnixFileMode(file).ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user