Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// limitations under the License.

using OpenSandbox.Adapters;
using OpenSandbox.Config;
using OpenSandbox.Core;
using OpenSandbox.Internal;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -58,7 +59,7 @@ public LifecycleStack CreateLifecycleStack(CreateLifecycleStackOptions options)
/// <inheritdoc />
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,
Expand Down Expand Up @@ -100,7 +101,7 @@ public ExecdStack CreateExecdStack(CreateExecdStackOptions options)
/// <inheritdoc />
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,
Expand All @@ -115,4 +116,34 @@ public EgressStack CreateEgressStack(CreateEgressStackOptions options)
CredentialVault = egress
};
}

internal static IReadOnlyDictionary<string, string> BuildDataPlaneHeaders(
ConnectionConfig connectionConfig,
IReadOnlyDictionary<string, string>? endpointHeaders)
{
var headers = new Dictionary<string, string>(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 (connectionConfig.ApiKey is { Length: > 0 } apiKey)
{
headers[Constants.ApiKeyHeader] = apiKey;
}

return headers;
}
}
7 changes: 7 additions & 0 deletions sdks/sandbox/csharp/src/OpenSandbox/HttpClientProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// limitations under the License.

using OpenSandbox.Config;
using OpenSandbox.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

Expand All @@ -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);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// 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<string, string>
{
["open-sandbox-api-key"] = "explicit-secret",
["X-Custom-Header"] = "custom-value"
},
UseServerProxy = false
});

var headers = DefaultAdapterFactory.BuildDataPlaneHeaders(
config,
new Dictionary<string, string> { ["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 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");
}
}
48 changes: 40 additions & 8 deletions sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,34 @@ import type {
LifecycleStack,
} from "./adapterFactory.js";

const API_KEY_HEADER = "OPEN-SANDBOX-API-KEY";

function createDataPlaneHeaders(
connectionHeaders: Record<string, string>,
endpointHeaders: Record<string, string> | undefined,
useServerProxy: boolean,
apiKey: string | undefined,
): Record<string, string> {
const headers: Record<string, string> = {
...connectionHeaders,
...(endpointHeaders ?? {}),
};

if (!useServerProxy || apiKey) {
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === API_KEY_HEADER.toLowerCase()) {
delete headers[key];
}
}
}

if (useServerProxy && apiKey) {
headers[API_KEY_HEADER] = apiKey;
Comment thread
ruirui6946 marked this conversation as resolved.
Outdated
}

return headers;
}

export class DefaultAdapterFactory implements AdapterFactory {
createLifecycleStack(opts: CreateLifecycleStackOptions): LifecycleStack {
const lifecycleClient = createLifecycleClient({
Expand All @@ -51,10 +79,12 @@ export class DefaultAdapterFactory implements AdapterFactory {
}

createExecdStack(opts: CreateExecdStackOptions): ExecdStack {
const headers: Record<string, string> = {
...(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,
Expand Down Expand Up @@ -91,10 +121,12 @@ export class DefaultAdapterFactory implements AdapterFactory {
}

createEgressStack(opts: CreateEgressStackOptions): EgressStack {
const headers: Record<string, string> = {
...(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,
Expand Down
114 changes: 114 additions & 0 deletions sdks/sandbox/javascript/tests/data-plane-auth.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// 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) {
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" },
});
const egress = factory.createEgressStack({
connectionConfig,
egressBaseUrl: "http://egress.opensandbox.test",
endpointHeaders: { "X-Endpoint-Token": "egress-token" },
});

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("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");
});
Loading