Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
15 changes: 15 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,21 @@ Omitted sections and keys use the defaults shown above. An empty
`workspaces.allowedRoots` uses the current working directory. Unknown keys are
rejected so spelling mistakes cannot silently alter behavior.

### Reverse proxies and tunnels

When `server.host` is `127.0.0.1`, `localhost`, or `::1`, DevSpace automatically
trusts only loopback reverse proxies. This is the normal topology for Tailscale
Serve/Funnel, Cloudflare Tunnel, and similar local tunnel agents, and allows
their forwarded client IP headers to work with OAuth rate limiting without
trusting arbitrary remote proxies.

Leave `server.trustProxy` set to `false` for that topology. Set it to `true`
only when DevSpace is intentionally behind a trusted non-loopback proxy; that
explicit setting tells Express to trust forwarded proxy information generally.

DevSpace keeps HTTP connections open for five minutes so a tunnel can safely
reuse them between MCP calls instead of racing Node's short default timeout.

`oauth.allowedResourceUrls` accepts exact alternate MCP resource URLs for
clients that connect through a resource alias, such as a secure MCP tunnel.
The normal `server.publicBaseUrl` `/mcp` resource remains allowed automatically.
Expand Down
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ async function serve(): Promise<void> {

const config = loadConfig();
await runStartupWorktreeCleanup(config);
const { createServer } = await import("./server.js");
const { configureHttpServer, createServer } = await import("./server.js");
const { app, close, localAgentProviders } = createServer(config);
const httpServer = app.listen(config.port, config.host, () => {
console.log(`devspace listening on http://${config.host}:${config.port}/mcp`);
Expand All @@ -338,6 +338,7 @@ async function serve(): Promise<void> {
console.log(`logging: ${config.logging.level} ${config.logging.format}`);
console.log(`subagent providers: ${formatLocalAgentProviderStatusSummary(localAgentProviders)}`);
});
configureHttpServer(httpServer);

let shuttingDown = false;
const shutdown = async () => {
Expand Down
52 changes: 50 additions & 2 deletions src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { access, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { createServer as createHttpServer } from "node:http";
import { platform, tmpdir } from "node:os";
import { join } from "node:path";
import test, { type TestContext } from "node:test";
Expand All @@ -14,7 +15,13 @@ import { buildLocalAgentProviderStatuses } from "./local-agent-catalog.js";
import type { SubagentsConfig } from "./local-agent-config.js";
import { createReviewCheckpointManager } from "./review-checkpoints.js";
import { ProcessSessionManager } from "./process-sessions.js";
import { createMcpServer, createServer } from "./server.js";
import {
configureHttpServer,
createMcpServer,
createServer,
DEVSPACE_HTTP_HEADERS_TIMEOUT_MS,
DEVSPACE_HTTP_KEEP_ALIVE_TIMEOUT_MS,
} from "./server.js";
import { SqliteWorkspaceStore } from "./workspace-store.js";
import { WorkspaceRegistry } from "./workspaces.js";
import { writeTestDevspaceConfig } from "./test-support/config.test.js";
Expand Down Expand Up @@ -470,6 +477,43 @@ test("open_workspace scopes checkout reuse to OpenAI session metadata", async (t
assert.ok(Array.isArray(structuredContent(unscoped).agents_files));
});

test("server trusts only loopback reverse proxies automatically", async (t) => {
const root = await mkdtemp(join(tmpdir(), "devspace-trust-proxy-test-"));
t.after(async () => rm(root, { recursive: true, force: true }));

const cases = [
{ host: "127.0.0.1", trustProxy: false, expected: "loopback" },
{ host: "localhost", trustProxy: false, expected: "loopback" },
{ host: "0.0.0.0", trustProxy: false, expected: false },
{ host: "0.0.0.0", trustProxy: true, expected: true },
] as const;

for (const [index, testCase] of cases.entries()) {
const config = loadConfig(writeTestDevspaceConfig(join(root, `config-${index}`), {
server: {
host: testCase.host,
port: 1,
publicBaseUrl: "https://example.test",
trustProxy: testCase.trustProxy,
},
storage: { stateDir: join(root, `state-${index}`) },
workspaces: { allowedRoots: [root] },
logging: { level: "silent" },
}));
const running = createServer(config, { incomingArtifactAdapters: [] });
assert.equal(running.app.get("trust proxy"), testCase.expected);
await running.close();
}
});

test("HTTP listener uses a tunnel-safe keep-alive timeout", () => {
const httpServer = createHttpServer();
configureHttpServer(httpServer);

assert.equal(httpServer.keepAliveTimeout, DEVSPACE_HTTP_KEEP_ALIVE_TIMEOUT_MS);
assert.equal(httpServer.headersTimeout, DEVSPACE_HTTP_HEADERS_TIMEOUT_MS);
});

test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) => {
const { root, localBaseUrl, accessToken } = await httpServerFixture(
t,
Expand Down Expand Up @@ -503,6 +547,8 @@ test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) =
{},
);
assert.equal(listed.status, 200, await listed.clone().text());
assert.ok(Number(listed.headers.get("content-length")) > 0);
assert.equal(listed.headers.get("transfer-encoding"), null);
const listBody = await listed.json() as {
result?: { tools?: Array<{ name?: string }> };
};
Expand Down Expand Up @@ -671,6 +717,7 @@ interface HttpServerFixture {
root: string;
localBaseUrl: string;
accessToken: string;
httpServer: ReturnType<typeof createHttpServer>;
running: ReturnType<typeof createServer>;
}

Expand All @@ -694,6 +741,7 @@ async function httpServerFixture(
const running = createServer(config, { incomingArtifactAdapters: [] });
const httpServer = running.app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => httpServer.once("listening", resolve));
configureHttpServer(httpServer);

t.after(async () => {
await new Promise<void>((resolve, reject) => {
Expand All @@ -711,7 +759,7 @@ async function httpServerFixture(
config.publicBaseUrl,
ownerToken,
);
return { root, localBaseUrl, accessToken, running };
return { root, localBaseUrl, accessToken, httpServer, running };
}

async function fixture(
Expand Down
Loading