diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Factory/DefaultAdapterFactory.cs b/sdks/sandbox/csharp/src/OpenSandbox/Factory/DefaultAdapterFactory.cs index 0f0bc0d09..0fc576fef 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Factory/DefaultAdapterFactory.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Factory/DefaultAdapterFactory.cs @@ -13,6 +13,7 @@ // limitations under the License. using OpenSandbox.Adapters; +using OpenSandbox.Config; using OpenSandbox.Core; using OpenSandbox.Internal; using Microsoft.Extensions.Logging; @@ -58,7 +59,7 @@ public LifecycleStack CreateLifecycleStack(CreateLifecycleStackOptions options) /// public ExecdStack CreateExecdStack(CreateExecdStackOptions options) { - var headers = options.ExecdHeaders ?? options.ConnectionConfig.Headers; + var headers = BuildDataPlaneHeaders(options.ConnectionConfig, options.ExecdHeaders); var clientWrapper = new HttpClientWrapper( options.HttpClientProvider.HttpClient, @@ -100,7 +101,7 @@ public ExecdStack CreateExecdStack(CreateExecdStackOptions options) /// public EgressStack CreateEgressStack(CreateEgressStackOptions options) { - var headers = options.EgressHeaders ?? options.ConnectionConfig.Headers; + var headers = BuildDataPlaneHeaders(options.ConnectionConfig, options.EgressHeaders); var clientWrapper = new HttpClientWrapper( options.HttpClientProvider.HttpClient, @@ -115,4 +116,35 @@ public EgressStack CreateEgressStack(CreateEgressStackOptions options) CredentialVault = egress }; } + + internal static IReadOnlyDictionary BuildDataPlaneHeaders( + ConnectionConfig connectionConfig, + IReadOnlyDictionary? endpointHeaders) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var header in connectionConfig.Headers) + { + headers[header.Key] = header.Value; + } + + if (endpointHeaders != null) + { + foreach (var header in endpointHeaders) + { + headers[header.Key] = header.Value; + } + } + + if (!connectionConfig.UseServerProxy) + { + headers.Remove(Constants.ApiKeyHeader); + } + else if (!headers.ContainsKey(Constants.ApiKeyHeader) && + connectionConfig.ApiKey is { Length: > 0 } apiKey) + { + headers[Constants.ApiKeyHeader] = apiKey; + } + + return headers; + } } diff --git a/sdks/sandbox/csharp/src/OpenSandbox/HttpClientProvider.cs b/sdks/sandbox/csharp/src/OpenSandbox/HttpClientProvider.cs index 522983e62..a0d51165e 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/HttpClientProvider.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/HttpClientProvider.cs @@ -13,6 +13,7 @@ // limitations under the License. using OpenSandbox.Config; +using OpenSandbox.Core; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -32,6 +33,12 @@ internal HttpClientProvider(ConnectionConfig connectionConfig, ILoggerFactory lo _logger.LogDebug("Creating HTTP clients for SDK instance"); HttpClient = connectionConfig.CreateHttpClient(); SseHttpClient = connectionConfig.CreateSseHttpClient(); + + // These clients are shared by lifecycle and data-plane adapters. Keep + // tenant credentials request-scoped so direct execd/egress calls cannot + // inherit them through DefaultRequestHeaders. + HttpClient.DefaultRequestHeaders.Remove(Constants.ApiKeyHeader); + SseHttpClient.DefaultRequestHeaders.Remove(Constants.ApiKeyHeader); } /// diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/DefaultAdapterFactoryTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/DefaultAdapterFactoryTests.cs new file mode 100644 index 000000000..8265986de --- /dev/null +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/DefaultAdapterFactoryTests.cs @@ -0,0 +1,98 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using OpenSandbox.Config; +using OpenSandbox.Core; +using OpenSandbox.Factory; +using Xunit; + +namespace OpenSandbox.Tests; + +public class DefaultAdapterFactoryTests +{ + [Fact] + public void BuildDataPlaneHeaders_InDirectMode_ShouldRemoveApiKeyCaseInsensitively() + { + var config = new ConnectionConfig(new ConnectionConfigOptions + { + ApiKey = "tenant-secret", + Headers = new Dictionary + { + ["open-sandbox-api-key"] = "explicit-secret", + ["X-Custom-Header"] = "custom-value" + }, + UseServerProxy = false + }); + + var headers = DefaultAdapterFactory.BuildDataPlaneHeaders( + config, + new Dictionary { ["X-Endpoint-Token"] = "route-token" }); + + headers.Keys.Should().NotContain(key => + key.Equals(Constants.ApiKeyHeader, StringComparison.OrdinalIgnoreCase)); + headers["X-Custom-Header"].Should().Be("custom-value"); + headers["X-Endpoint-Token"].Should().Be("route-token"); + } + + [Fact] + public void BuildDataPlaneHeaders_InServerProxyMode_ShouldRetainApiKey() + { + var config = new ConnectionConfig(new ConnectionConfigOptions + { + ApiKey = "tenant-secret", + UseServerProxy = true + }); + + var headers = DefaultAdapterFactory.BuildDataPlaneHeaders(config, null); + + headers[Constants.ApiKeyHeader].Should().Be("tenant-secret"); + } + + [Fact] + public void BuildDataPlaneHeaders_InServerProxyMode_ShouldPreferEndpointApiKey() + { + var config = new ConnectionConfig(new ConnectionConfigOptions + { + ApiKey = "tenant-secret", + UseServerProxy = true + }); + + var headers = DefaultAdapterFactory.BuildDataPlaneHeaders( + config, + new Dictionary + { + ["open-sandbox-api-key"] = "endpoint-secret" + }); + + headers[Constants.ApiKeyHeader].Should().Be("endpoint-secret"); + } + + [Fact] + public void SharedHttpClients_ShouldNotCarryTenantApiKeyByDefault() + { + var config = new ConnectionConfig(new ConnectionConfigOptions + { + ApiKey = "tenant-secret", + UseServerProxy = false + }); + + using var provider = new HttpClientProvider(config, NullLoggerFactory.Instance); + + provider.HttpClient.DefaultRequestHeaders.Contains(Constants.ApiKeyHeader).Should().BeFalse(); + provider.SseHttpClient.DefaultRequestHeaders.Contains(Constants.ApiKeyHeader).Should().BeFalse(); + config.Headers[Constants.ApiKeyHeader].Should().Be("tenant-secret"); + } +} diff --git a/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts b/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts index 2465006ca..2c9cc401c 100644 --- a/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts +++ b/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts @@ -34,6 +34,41 @@ import type { LifecycleStack, } from "./adapterFactory.js"; +const API_KEY_HEADER = "OPEN-SANDBOX-API-KEY"; + +function createDataPlaneHeaders( + connectionHeaders: Record, + endpointHeaders: Record | undefined, + useServerProxy: boolean, + apiKey: string | undefined, +): Record { + const headers: Record = { + ...connectionHeaders, + ...(endpointHeaders ?? {}), + }; + const endpointApiKey = Object.entries(endpointHeaders ?? {}).find( + ([key]) => key.toLowerCase() === API_KEY_HEADER.toLowerCase(), + ); + + if (!useServerProxy || endpointApiKey || apiKey) { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === API_KEY_HEADER.toLowerCase()) { + delete headers[key]; + } + } + } + + if (useServerProxy) { + if (endpointApiKey) { + headers[endpointApiKey[0]] = endpointApiKey[1]; + } else if (apiKey) { + headers[API_KEY_HEADER] = apiKey; + } + } + + return headers; +} + export class DefaultAdapterFactory implements AdapterFactory { createLifecycleStack(opts: CreateLifecycleStackOptions): LifecycleStack { const lifecycleClient = createLifecycleClient({ @@ -51,10 +86,12 @@ export class DefaultAdapterFactory implements AdapterFactory { } createExecdStack(opts: CreateExecdStackOptions): ExecdStack { - const headers: Record = { - ...(opts.connectionConfig.headers ?? {}), - ...(opts.endpointHeaders ?? {}), - }; + const headers = createDataPlaneHeaders( + opts.connectionConfig.headers, + opts.endpointHeaders, + opts.connectionConfig.useServerProxy, + opts.connectionConfig.apiKey, + ); const execdClient = createExecdClient({ baseUrl: opts.execdBaseUrl, headers, @@ -91,10 +128,12 @@ export class DefaultAdapterFactory implements AdapterFactory { } createEgressStack(opts: CreateEgressStackOptions): EgressStack { - const headers: Record = { - ...(opts.connectionConfig.headers ?? {}), - ...(opts.endpointHeaders ?? {}), - }; + const headers = createDataPlaneHeaders( + opts.connectionConfig.headers, + opts.endpointHeaders, + opts.connectionConfig.useServerProxy, + opts.connectionConfig.apiKey, + ); const egressClient = createEgressClient({ baseUrl: opts.egressBaseUrl, headers, diff --git a/sdks/sandbox/javascript/tests/data-plane-auth.test.mjs b/sdks/sandbox/javascript/tests/data-plane-auth.test.mjs new file mode 100644 index 000000000..4db3b18af --- /dev/null +++ b/sdks/sandbox/javascript/tests/data-plane-auth.test.mjs @@ -0,0 +1,132 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ConnectionConfig, DefaultAdapterFactory } from "../dist/index.js"; + +const API_KEY_HEADER = "open-sandbox-api-key"; + +function createConfig(useServerProxy, requests, includeExplicitApiKey = false) { + const fetchImpl = async (input, init) => { + const request = new Request(input, init); + requests.push(request); + + if (new URL(request.url).pathname === "/ping") { + return new Response("", { status: 200 }); + } + if (new URL(request.url).pathname === "/policy") { + return Response.json({ policy: { defaultAction: "deny", egress: [] } }); + } + return Response.json({ + id: "sandbox-1", + status: { state: "Running" }, + entrypoint: ["sleep", "infinity"], + createdAt: "2026-09-01T00:00:00Z", + expiresAt: null, + }); + }; + + const headers = { "X-Custom-Header": "custom-value" }; + if (includeExplicitApiKey) { + headers["open-sandbox-api-key"] = "explicit-secret"; + } + + const config = new ConnectionConfig({ + domain: "api.opensandbox.test", + apiKey: "tenant-secret", + headers, + useServerProxy, + }); + config._fetch = fetchImpl; + config._sseFetch = fetchImpl; + return config; +} + +async function sendDataPlaneRequests(useServerProxy, useEndpointApiKeys = false) { + const requests = []; + const connectionConfig = createConfig( + useServerProxy, + requests, + !useServerProxy, + ); + const factory = new DefaultAdapterFactory(); + const execd = factory.createExecdStack({ + connectionConfig, + execdBaseUrl: "http://execd.opensandbox.test", + endpointHeaders: { + "X-Endpoint-Token": "execd-token", + ...(useEndpointApiKeys + ? { "open-sandbox-api-key": "execd-secret" } + : {}), + }, + }); + const egress = factory.createEgressStack({ + connectionConfig, + egressBaseUrl: "http://egress.opensandbox.test", + endpointHeaders: { + "X-Endpoint-Token": "egress-token", + ...(useEndpointApiKeys + ? { "open-sandbox-api-key": "egress-secret" } + : {}), + }, + }); + + await execd.health.ping(); + await egress.egress.getPolicy(); + return requests; +} + +test("direct execd and egress requests omit the tenant API key", async () => { + const requests = await sendDataPlaneRequests(false); + + assert.equal(requests.length, 2); + for (const request of requests) { + assert.equal(request.headers.has(API_KEY_HEADER), false); + assert.equal(request.headers.get("x-custom-header"), "custom-value"); + assert.ok(request.headers.get("x-endpoint-token")); + } +}); + +test("server-proxied execd and egress requests retain the tenant API key", async () => { + const requests = await sendDataPlaneRequests(true); + + assert.equal(requests.length, 2); + for (const request of requests) { + assert.equal(request.headers.get(API_KEY_HEADER), "tenant-secret"); + } +}); + +test("server-proxied requests preserve endpoint-specific API keys", async () => { + const requests = await sendDataPlaneRequests(true, true); + + assert.equal(requests.length, 2); + assert.equal(requests[0].headers.get(API_KEY_HEADER), "execd-secret"); + assert.equal(requests[1].headers.get(API_KEY_HEADER), "egress-secret"); +}); + +test("lifecycle requests retain the tenant API key in direct mode", async () => { + const requests = []; + const connectionConfig = createConfig(false, requests); + const lifecycle = new DefaultAdapterFactory().createLifecycleStack({ + connectionConfig, + lifecycleBaseUrl: connectionConfig.getBaseUrl(), + }); + + await lifecycle.sandboxes.getSandbox("sandbox-1"); + + assert.equal(requests.length, 1); + assert.equal(requests[0].headers.get(API_KEY_HEADER), "tenant-secret"); +});