fix: harden server reliability and OAuth security

This commit is contained in:
kst
2026-08-21 17:19:13 +09:00
parent b2aab2c520
commit 080030c764
16 changed files with 946 additions and 100 deletions
+167
View File
@@ -0,0 +1,167 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import type { AddressInfo } from "node:net";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadConfig } from "../src/config.js";
import { startHttpServer } from "../src/http-server.js";
import { createServices } from "../src/mcp-server.js";
import { RemoteDevOAuthProvider } from "../src/oauth.js";
async function reservePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const port = (server.address() as AddressInfo).port;
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
return port;
}
describe("OAuth endpoint security boundaries", () => {
it("does not trust spoofed forwarded IPs unless a proxy is explicitly configured", async () => {
const temporaryDirectory = await mkdtemp(
path.join(os.tmpdir(), "cokacremote-auth-boundary-test-"),
);
const port = await reservePort();
const baseUrl = `http://127.0.0.1:${port}`;
const config = loadConfig(
{
MCP_OAUTH_ENABLED: "true",
MCP_OAUTH_APPROVAL_KEY: "oauth-approval-key",
MCP_PUBLIC_URL: baseUrl,
MCP_OAUTH_STATE_FILE: path.join(temporaryDirectory, "oauth-state.json"),
MCP_HOST: "127.0.0.1",
MCP_PORT: String(port),
MCP_DEFAULT_CWD: temporaryDirectory,
},
temporaryDirectory,
);
const running = await startHttpServer(config, createServices(config));
try {
const approvalKeyAsBearer = await fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: {
authorization: "Bearer oauth-approval-key",
"content-type": "application/json",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
});
expect(approvalKeyAsBearer.status).toBe(401);
const statuses: number[] = [];
for (let index = 1; index <= 21; index += 1) {
const response = await fetch(`${baseUrl}/register`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-forwarded-for": `203.0.113.${index}`,
},
body: JSON.stringify({
redirect_uris: ["https://chatgpt.com/connector/oauth/security-test"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: `security-test-${index}`,
scope: "mcp:tools",
}),
});
statuses.push(response.status);
}
expect(statuses.slice(0, 20)).toEqual(Array(20).fill(201));
expect(statuses[20]).toBe(429);
} finally {
await running.close();
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
it("rolls back failed state writes and revokes an entire token grant", async () => {
const temporaryDirectory = await mkdtemp(
path.join(os.tmpdir(), "cokacremote-oauth-store-test-"),
);
const baseEnvironment = {
MCP_OAUTH_ENABLED: "true",
MCP_OAUTH_APPROVAL_KEY: "oauth-approval-key",
MCP_PUBLIC_URL: "http://127.0.0.1:34567",
};
const metadata = {
client_id: "security-store-client",
redirect_uris: ["https://chatgpt.com/connector/oauth/security-store-test"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
client_name: "security store test",
scope: "mcp:tools",
};
try {
const blockedDirectory = path.join(temporaryDirectory, "blocked-state-directory");
await mkdir(blockedDirectory);
const failedProvider = new RemoteDevOAuthProvider(
loadConfig(
{
...baseEnvironment,
MCP_OAUTH_STATE_FILE: path.join(blockedDirectory, "state.json"),
},
temporaryDirectory,
),
);
await expect(
failedProvider.clientsStore.getClient(metadata.client_id),
).resolves.toBeUndefined();
await rm(blockedDirectory, { recursive: true, force: true });
await writeFile(blockedDirectory, "block state persistence");
await expect(
failedProvider.clientsStore.registerClient(
metadata as Parameters<typeof failedProvider.clientsStore.registerClient>[0],
),
).rejects.toThrow();
await expect(
failedProvider.clientsStore.getClient(metadata.client_id),
).resolves.toBeUndefined();
const stateFile = path.join(temporaryDirectory, "valid-state.json");
const provider = new RemoteDevOAuthProvider(
loadConfig(
{ ...baseEnvironment, MCP_OAUTH_STATE_FILE: stateFile },
temporaryDirectory,
),
);
const client = await provider.clientsStore.registerClient(
metadata as Parameters<typeof provider.clientsStore.registerClient>[0],
);
const resource = "http://127.0.0.1:34567/mcp";
const pair = await provider.clientsStore.issueTokenPair(
client.client_id,
["mcp:tools"],
resource,
);
expect(pair.refresh_token).toBeTypeOf("string");
await provider.clientsStore.revoke(pair.refresh_token!, client.client_id);
await expect(
provider.clientsStore.getAccessToken(pair.access_token),
).resolves.toBeUndefined();
await expect(
provider.clientsStore.rotateRefreshToken(
pair.refresh_token!,
client.client_id,
resource,
undefined,
),
).resolves.toMatchObject({ status: "invalid" });
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
});
+57
View File
@@ -22,11 +22,23 @@ describe("loadConfig", () => {
expect(config).toMatchObject({
port: 4321,
defaultCwd: "/",
trustProxyHops: 0,
authToken: "secret",
allowedHosts: ["mcp.example.com", "localhost"],
});
});
it("rejects partial integers and ports outside the valid range", () => {
for (const value of ["3000oops", "3000.9", "70000"]) {
expect(() =>
loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_PORT: value }, "/tmp"),
).toThrow("MCP_PORT must be an integer between 1 and 65535");
}
expect(
loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_PORT: " 4321 " }, "/tmp").port,
).toBe(4321);
});
it("requires public HTTPS metadata when OAuth is enabled", () => {
expect(() =>
loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_OAUTH_ENABLED: "true" }, "/tmp"),
@@ -43,9 +55,54 @@ describe("loadConfig", () => {
);
expect(config).toMatchObject({
oauthEnabled: true,
oauthApprovalKey: "secret",
oauthIssuerUrl: "https://mcp.example.com/",
oauthResourceUrl: "https://mcp.example.com/mcp",
oauthStateFile: "/tmp/oauth-state.json",
});
});
it("supports OAuth-only authentication with a separate approval key", () => {
const config = loadConfig(
{
MCP_OAUTH_ENABLED: "true",
MCP_OAUTH_APPROVAL_KEY: "separate-oauth-approval-key",
MCP_PUBLIC_URL: "https://mcp.example.com",
MCP_TRUST_PROXY_HOPS: "1",
},
"/tmp",
);
expect(config).toMatchObject({
authToken: undefined,
oauthApprovalKey: "separate-oauth-approval-key",
trustProxyHops: 1,
});
expect(() =>
loadConfig(
{
MCP_OAUTH_ENABLED: "true",
MCP_PUBLIC_URL: "https://mcp.example.com",
},
"/tmp",
),
).toThrow("MCP_OAUTH_APPROVAL_KEY");
});
it("rejects unsafe proxy trust and OAuth URL settings", () => {
expect(() =>
loadConfig({ MCP_AUTH_TOKEN: "secret", MCP_TRUST_PROXY_HOPS: "17" }, "/tmp"),
).toThrow("MCP_TRUST_PROXY_HOPS must be an integer between 0 and 16");
expect(() =>
loadConfig(
{
MCP_AUTH_TOKEN: "secret",
MCP_OAUTH_ENABLED: "true",
MCP_OAUTH_ISSUER: "https://user:password@mcp.example.com",
MCP_OAUTH_RESOURCE: "https://mcp.example.com/mcp",
},
"/tmp",
),
).toThrow("must not contain user credentials");
});
});
+34
View File
@@ -204,6 +204,22 @@ describe("FileService", () => {
"base64",
);
expect(binary.content).toBe("//4=");
const invalidEdit = Buffer.from([0xff, 0x78]);
await files.writeFileContent(
"invalid-edit.bin",
undefined,
invalidEdit.toString("base64"),
"base64",
"overwrite",
true,
);
await expect(
files.replaceInFile("invalid-edit.bin", undefined, "x", "y", false, 1),
).rejects.toThrow("not valid UTF-8");
expect(await readFile(path.join(temporaryDirectory, "invalid-edit.bin"))).toEqual(
invalidEdit,
);
});
it("applies an explicit file mode when overwriting an existing file", async () => {
@@ -256,4 +272,22 @@ describe("FileService", () => {
expect(await readFile(path.join(temporaryDirectory, "destination/value.txt"), "utf8"))
.toBe("destination");
});
it("preserves an existing move destination when the source is missing", async () => {
await files.writeFileContent(
"destination.txt",
undefined,
"valuable",
"utf8",
"overwrite",
true,
);
await expect(
files.movePath("missing.txt", "destination.txt", undefined, true),
).rejects.toThrow(/ENOENT|no such file/i);
expect(await readFile(path.join(temporaryDirectory, "destination.txt"), "utf8")).toBe(
"valuable",
);
});
});
+19
View File
@@ -67,6 +67,25 @@ describe("remote development MCP server", () => {
expect(response.status).toBe(401);
});
it("authenticates MCP requests before parsing their JSON body", async () => {
const unauthenticated = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{",
});
expect(unauthenticated.status).toBe(401);
const authenticated = await fetch(endpoint, {
method: "POST",
headers: {
authorization: "Bearer integration-secret",
"content-type": "application/json",
},
body: "{",
});
expect(authenticated.status).toBe(400);
});
it("lists tools and executes script and file workflows", async () => {
const client = new Client({ name: "integration-test", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(endpoint, {
+82 -7
View File
@@ -1,5 +1,5 @@
import { createHash, randomBytes } from "node:crypto";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises";
import type { AddressInfo } from "node:net";
import { createServer } from "node:net";
import os from "node:os";
@@ -40,14 +40,16 @@ describe("OAuth 2.1 MCP authorization", () => {
beforeAll(async () => {
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "remote-dev-mcp-oauth-test-"));
stateFile = path.join(temporaryDirectory, "oauth", "state.json");
const stateDirectory = path.join(temporaryDirectory, "oauth");
await mkdir(stateDirectory, { mode: 0o755 });
stateFile = path.join(stateDirectory, "state.json");
const port = await reservePort();
baseUrl = `http://127.0.0.1:${port}`;
resourceUrl = `${baseUrl}/mcp`;
config = loadConfig(
{
MCP_AUTH_TOKEN: "oauth-login-secret",
MCP_OAUTH_ENABLED: "true",
MCP_OAUTH_APPROVAL_KEY: "oauth-login-secret",
MCP_PUBLIC_URL: baseUrl,
MCP_OAUTH_STATE_FILE: stateFile,
MCP_HOST: "127.0.0.1",
@@ -107,9 +109,54 @@ describe("OAuth 2.1 MCP authorization", () => {
registration_endpoint: `${baseUrl}/register`,
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: expect.arrayContaining(["none"]),
revocation_endpoint_auth_methods_supported: expect.arrayContaining(["none"]),
});
const redirectUri = "https://chatgpt.com/connector/oauth/test-callback";
for (const invalidMetadata of [
{
redirect_uris: ["http://attacker.example/callback"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"],
},
{
redirect_uris: [redirectUri],
token_endpoint_auth_method: "private_key_jwt",
grant_types: ["authorization_code"],
response_types: ["code"],
},
{
redirect_uris: [],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"],
},
]) {
const invalidRegistration = await fetch(`${baseUrl}/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(invalidMetadata),
});
expect(invalidRegistration.status).toBe(400);
expect(await invalidRegistration.json()).toMatchObject({
error: "invalid_client_metadata",
});
}
const defaultedRegistration = await fetch(`${baseUrl}/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ redirect_uris: [redirectUri] }),
});
expect(defaultedRegistration.status).toBe(201);
expect(await defaultedRegistration.json()).toMatchObject({
token_endpoint_auth_method: "client_secret_post",
grant_types: ["authorization_code"],
response_types: ["code"],
client_secret: expect.any(String),
});
const registrationResponse = await fetch(`${baseUrl}/register`, {
method: "POST",
headers: { "content-type": "application/json" },
@@ -143,9 +190,7 @@ describe("OAuth 2.1 MCP authorization", () => {
redirect: "manual",
});
expect(loginPage.status).toBe(200);
expect(loginPage.headers.get("content-security-policy")).toContain(
"form-action 'self' https://chatgpt.com",
);
expect(loginPage.headers.get("content-security-policy")).toContain("form-action 'self'");
expect(await loginPage.text()).toContain("MCP 인증키");
const rejectedLogin = await fetch(`${baseUrl}/authorize`, {
@@ -162,7 +207,7 @@ describe("OAuth 2.1 MCP authorization", () => {
body: form({ ...authorizationValues, access_key: "oauth-login-secret" }),
redirect: "manual",
});
expect(approvedLogin.status).toBe(302);
expect(approvedLogin.status).toBe(303);
const callback = new URL(approvedLogin.headers.get("location")!);
expect(callback.origin + callback.pathname).toBe(redirectUri);
expect(callback.searchParams.get("state")).toBe("oauth-test-state");
@@ -224,6 +269,35 @@ describe("OAuth 2.1 MCP authorization", () => {
expect(refreshed.access_token).not.toBe(tokens.access_token);
expect(refreshed.refresh_token).not.toBe(tokens.refresh_token);
await running.close();
running = await startHttpServer(config, createServices(config));
const replayedRefresh = await fetch(`${baseUrl}/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: form({
grant_type: "refresh_token",
client_id: registered.client_id,
refresh_token: tokens.refresh_token,
resource: resourceUrl,
}),
});
expect(replayedRefresh.status).toBe(400);
expect(await replayedRefresh.json()).toMatchObject({ error: "invalid_grant" });
const revokedSuccessor = await fetch(`${baseUrl}/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: form({
grant_type: "refresh_token",
client_id: registered.client_id,
refresh_token: refreshed.refresh_token,
resource: resourceUrl,
}),
});
expect(revokedSuccessor.status).toBe(400);
expect(await revokedSuccessor.json()).toMatchObject({ error: "invalid_grant" });
const revokeResponse = await fetch(`${baseUrl}/revoke`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -242,6 +316,7 @@ describe("OAuth 2.1 MCP authorization", () => {
expect(revokedRequest.status).toBe(401);
expect((await stat(stateFile)).mode & 0o777).toBe(0o600);
expect((await stat(path.dirname(stateFile))).mode & 0o777).toBe(0o755);
const persisted = await readFile(stateFile, "utf8");
expect(persisted).not.toContain(tokens.access_token);
expect(persisted).not.toContain(tokens.refresh_token);
+56
View File
@@ -76,4 +76,60 @@ describe("ProcessManager", () => {
expect(result.timedOut).toBe(true);
expect(result.error).toContain("timeout");
});
it("handles a rejected initial stdin write without crashing the server", async () => {
manager = createManager();
const sessionId = manager.start({
executable: "/bin/bash",
args: ["-c", "true"],
commandForDisplay: "true",
cwd: process.cwd(),
stdin: "x".repeat(1024 * 1024),
});
await manager.waitForExit(sessionId, 2000);
await new Promise<void>((resolve) => setImmediate(resolve));
const result = await manager.read(sessionId);
expect(result.running).toBe(false);
expect(result.error).toMatch(/stdin write failed|EPIPE/i);
});
it("rejects a follow-up stdin write without emitting an unhandled error", async () => {
manager = createManager();
const sessionId = manager.start({
executable: "/bin/bash",
args: ["-c", "exec 0<&-; printf ready; sleep 2"],
commandForDisplay: "closed stdin",
cwd: process.cwd(),
});
expect((await manager.read(sessionId, { waitMs: 1000 })).stdout).toContain("ready");
await expect(manager.write(sessionId, "x".repeat(1024 * 1024))).rejects.toThrow();
await new Promise<void>((resolve) => setImmediate(resolve));
expect((await manager.read(sessionId)).error).toMatch(/stdin write failed|EPIPE/i);
});
it("preserves UTF-8 characters across paged process output", async () => {
manager = createManager();
const expected = `${"a".repeat(16 * 1024 - 1)}😀B`;
const encoded = Buffer.from(expected).toString("base64");
const sessionId = manager.start({
executable: process.execPath,
args: ["-e", `process.stdout.write(Buffer.from(${JSON.stringify(encoded)}, "base64"))`],
commandForDisplay: "unicode output",
cwd: process.cwd(),
});
await manager.waitForExit(sessionId, 2000);
const first = await manager.read(sessionId, { maxOutputBytes: 16 * 1024 });
const second = await manager.read(sessionId, {
afterSeq: first.nextSeq,
maxOutputBytes: 16 * 1024,
});
expect(first.hasMore).toBe(true);
expect(first.output + second.output).toBe(expected);
expect(first.output + second.output).not.toContain("");
});
});