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
+34 -8
View File
@@ -6,9 +6,11 @@ export interface AppConfig {
endpoint: string;
publicUrl: string | undefined;
allowedHosts: string[] | undefined;
trustProxyHops: number;
authToken: string | undefined;
allowNoAuth: boolean;
oauthEnabled: boolean;
oauthApprovalKey: string | undefined;
oauthIssuerUrl: string | undefined;
oauthResourceUrl: string | undefined;
oauthStateFile: string;
@@ -44,13 +46,18 @@ function parseInteger(
fallback: number,
name: string,
minimum: number,
maximum = Number.MAX_SAFE_INTEGER,
): number {
if (value === undefined || value === "") {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isSafeInteger(parsed) || parsed < minimum) {
throw new Error(`${name} must be an integer greater than or equal to ${minimum}`);
const normalized = value.trim();
const parsed = /^[+-]?\d+$/.test(normalized) ? Number(normalized) : Number.NaN;
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
const range = maximum === Number.MAX_SAFE_INTEGER
? `greater than or equal to ${minimum}`
: `between ${minimum} and ${maximum}`;
throw new Error(`${name} must be an integer ${range}`);
}
return parsed;
}
@@ -73,10 +80,16 @@ function normalizeOAuthUrl(value: string | undefined, name: string): string {
} catch {
throw new Error(`${name} must be an absolute URL`);
}
const isLoopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
const isLoopback =
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "[::1]";
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback)) {
throw new Error(`${name} must use HTTPS (HTTP is allowed only for loopback tests)`);
}
if (url.username || url.password) {
throw new Error(`${name} must not contain user credentials`);
}
if (url.search || url.hash) {
throw new Error(`${name} must not contain a query string or fragment`);
}
@@ -90,13 +103,18 @@ export function loadConfig(
const allowNoAuth = parseBoolean(env.MCP_ALLOW_NO_AUTH, false);
const authToken = env.MCP_AUTH_TOKEN?.trim() || undefined;
const oauthEnabled = parseBoolean(env.MCP_OAUTH_ENABLED, false);
if (!allowNoAuth && !authToken) {
const oauthApprovalKey = oauthEnabled
? env.MCP_OAUTH_APPROVAL_KEY?.trim() || authToken
: undefined;
if (!allowNoAuth && !authToken && !oauthEnabled) {
throw new Error(
"MCP_AUTH_TOKEN is required. Set MCP_ALLOW_NO_AUTH=true only when an upstream OAuth gateway or private network authenticates callers.",
);
}
if (oauthEnabled && !authToken) {
throw new Error("MCP_AUTH_TOKEN is required as the OAuth authorization access key");
if (oauthEnabled && !oauthApprovalKey) {
throw new Error(
"MCP_OAUTH_APPROVAL_KEY (or MCP_AUTH_TOKEN for backward compatibility) is required when OAuth is enabled",
);
}
const defaultCwd = path.resolve(env.MCP_DEFAULT_CWD?.trim() || processCwd);
@@ -118,13 +136,21 @@ export function loadConfig(
return {
host: env.MCP_HOST?.trim() || "0.0.0.0",
port: parseInteger(env.MCP_PORT, 3000, "MCP_PORT", 1),
port: parseInteger(env.MCP_PORT, 3000, "MCP_PORT", 1, 65_535),
endpoint,
publicUrl,
allowedHosts: allowedHosts && allowedHosts.length > 0 ? allowedHosts : undefined,
trustProxyHops: parseInteger(
env.MCP_TRUST_PROXY_HOPS,
0,
"MCP_TRUST_PROXY_HOPS",
0,
16,
),
authToken,
allowNoAuth,
oauthEnabled,
oauthApprovalKey,
oauthIssuerUrl,
oauthResourceUrl,
oauthStateFile: path.resolve(
+83 -12
View File
@@ -100,6 +100,16 @@ function decodeContent(data: string, encoding: FileContentEncoding): Buffer {
return encoding === "base64" ? decodeBase64(data) : Buffer.from(data, "utf8");
}
function isPathWithin(parentPath: string, candidatePath: string): boolean {
const relative = path.relative(parentPath, candidatePath);
return (
relative !== "" &&
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
function utf8SequenceLength(firstByte: number): number {
if (firstByte <= 0x7f) {
return 1;
@@ -418,7 +428,11 @@ export class FileService {
`${resolvedPath} is ${info.size} bytes; replace_in_file limit is ${this.#options.maxEditFileBytes}`,
);
}
const original = await readFile(resolvedPath, "utf8");
const originalBuffer = await readFile(resolvedPath);
if (!isUtf8(originalBuffer)) {
throw new Error(`${resolvedPath} is not valid UTF-8`);
}
const original = originalBuffer.toString("utf8");
const occurrences = original.split(oldText).length - 1;
const expected = expectedOccurrences ?? (replaceAll ? occurrences : 1);
if (occurrences !== expected) {
@@ -559,6 +573,13 @@ export class FileService {
if (source === destination) {
return { source, destination, moved: false, samePath: true };
}
await lstat(source);
if (isPathWithin(source, destination) || isPathWithin(destination, source)) {
throw new Error("Source and destination paths must not contain one another");
}
await mkdir(path.dirname(destination), { recursive: true });
let destinationBackup: string | undefined;
if (!overwrite) {
try {
await lstat(destination);
@@ -569,19 +590,69 @@ export class FileService {
}
}
} else {
await rm(destination, { recursive: true, force: true });
}
await mkdir(path.dirname(destination), { recursive: true });
try {
await rename(source, destination);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EXDEV") {
throw error;
try {
await lstat(destination);
destinationBackup = path.join(
path.dirname(destination),
`.cokacremote-move-backup-${randomUUID()}`,
);
await rename(destination, destinationBackup);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
await cp(source, destination, { recursive: true, force: overwrite });
await rm(source, { recursive: true, force: true });
}
return { source, destination, moved: true };
let destinationMayBePartial = false;
try {
try {
await rename(source, destination);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EXDEV") {
throw error;
}
destinationMayBePartial = true;
await cp(source, destination, {
recursive: true,
force: false,
errorOnExist: true,
});
try {
await rm(source, { recursive: true, force: true });
} catch (removeError) {
await rm(destination, { recursive: true, force: true }).catch(() => undefined);
throw removeError;
}
}
} catch (error) {
const rollbackErrors: string[] = [];
if (destinationBackup || destinationMayBePartial) {
await rm(destination, { recursive: true, force: true }).catch((rollbackError) => {
rollbackErrors.push(`remove partial destination: ${errorMessage(rollbackError)}`);
});
}
if (destinationBackup) {
await rename(destinationBackup, destination).catch((rollbackError) => {
rollbackErrors.push(`restore original destination: ${errorMessage(rollbackError)}`);
});
}
if (rollbackErrors.length > 0) {
throw new Error(`${errorMessage(error)}; rollback failed: ${rollbackErrors.join("; ")}`);
}
throw error;
}
const result: Record<string, unknown> = { source, destination, moved: true };
if (destinationBackup) {
try {
await rm(destinationBackup, { recursive: true, force: true });
} catch (error) {
result.warning = `Move succeeded but the old destination backup could not be removed: ${errorMessage(error)}`;
result.backupPath = destinationBackup;
}
}
return result;
}
async removePath(
+42 -17
View File
@@ -2,7 +2,11 @@ import { randomUUID } from "node:crypto";
import type { Server as HttpServer } from "node:http";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
import {
createOAuthMetadata,
mcpAuthRouter,
type AuthRouterOptions,
} from "@modelcontextprotocol/sdk/server/auth/router.js";
import express, { type Request, type Response } from "express";
import { createBearerAuth, createHostValidation } from "./auth.js";
@@ -54,7 +58,9 @@ export async function startHttpServer(
): Promise<RunningHttpServer> {
const app = express();
app.disable("x-powered-by");
app.set("trust proxy", 1);
if (config.trustProxyHops > 0) {
app.set("trust proxy", config.trustProxyHops);
}
app.use((request, response, next) => {
if (request.path !== config.endpoint) {
next();
@@ -90,7 +96,6 @@ export async function startHttpServer(
});
next();
});
app.use(express.json({ limit: config.maxRequestBody }));
app.use(createHostValidation(config));
const activeRequests = new Set<ActiveRequest>();
@@ -98,7 +103,7 @@ export async function startHttpServer(
const oauthProvider = config.oauthEnabled ? new RemoteDevOAuthProvider(config) : undefined;
if (oauthProvider) {
app.get("/.well-known/oauth-protected-resource", (_request, response) => {
response.json({
response.set("Access-Control-Allow-Origin", "*").json({
resource: oauthProvider.resourceUrl.href,
authorization_servers: [oauthProvider.issuerUrl.href],
scopes_supported: [...OAUTH_SCOPES],
@@ -106,18 +111,33 @@ export async function startHttpServer(
resource_name: "cokacremote",
});
});
app.use(
mcpAuthRouter({
provider: oauthProvider,
issuerUrl: oauthProvider.issuerUrl,
resourceServerUrl: oauthProvider.resourceUrl,
scopesSupported: [...OAUTH_SCOPES],
resourceName: "cokacremote",
clientRegistrationOptions: { clientSecretExpirySeconds: 0 },
}),
);
const oauthRouterOptions = {
provider: oauthProvider,
issuerUrl: oauthProvider.issuerUrl,
resourceServerUrl: oauthProvider.resourceUrl,
scopesSupported: [...OAUTH_SCOPES],
resourceName: "cokacremote",
} satisfies AuthRouterOptions;
const oauthMetadata = {
...createOAuthMetadata(oauthRouterOptions),
revocation_endpoint_auth_methods_supported: ["client_secret_post", "none"],
};
const issuerPath = oauthProvider.issuerUrl.pathname.replace(/\/$/, "");
const oauthMetadataPath = `/.well-known/oauth-authorization-server${issuerPath}`;
app.use((request, response, next) => {
if (
(request.method === "GET" || request.method === "HEAD") &&
request.path === oauthMetadataPath
) {
response.set("Access-Control-Allow-Origin", "*").json(oauthMetadata);
return;
}
next();
});
app.use(mcpAuthRouter(oauthRouterOptions));
}
const authenticate = createBearerAuth(config, oauthProvider);
const parseMcpJson = express.json({ limit: config.maxRequestBody });
app.get("/health", (_request, response) => {
response.json({
@@ -176,9 +196,14 @@ export async function startHttpServer(
rpcError(response, 405, "Stateless MCP accepts POST requests only");
};
app.post(config.endpoint, authenticate, (request, response) => {
void postHandler(request, response);
});
app.post(
config.endpoint,
authenticate,
parseMcpJson,
(request, response) => {
void postHandler(request, response);
},
);
app.get(config.endpoint, authenticate, methodNotAllowed);
app.delete(config.endpoint, authenticate, methodNotAllowed);
+201 -33
View File
@@ -1,12 +1,14 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js";
import {
InvalidClientMetadataError,
InvalidGrantError,
InvalidScopeError,
InvalidTargetError,
UnauthorizedClientError,
} from "@modelcontextprotocol/sdk/server/auth/errors.js";
import type {
AuthorizationParams,
@@ -26,11 +28,12 @@ import type { AppConfig } from "./config.js";
export const OAUTH_SCOPES = ["mcp:tools"] as const;
interface StoredToken {
type: "access" | "refresh";
type: "access" | "refresh" | "used_refresh";
clientId: string;
scopes: string[];
expiresAt: number;
resource: string;
grantId?: string;
}
interface PersistedOAuthState {
@@ -65,18 +68,100 @@ function randomToken(): string {
return randomBytes(32).toString("base64url");
}
const LOOPBACK_REDIRECT_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
const SUPPORTED_CLIENT_AUTH_METHODS = new Set(["none", "client_secret_post"]);
const SUPPORTED_GRANT_TYPES = new Set(["authorization_code", "refresh_token"]);
function clientMetadataProblem(value: unknown): string | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return "Client metadata must be an object";
}
const client = value as Partial<OAuthClientInformationFull>;
if (!Array.isArray(client.redirect_uris) || client.redirect_uris.length === 0) {
return "At least one redirect_uri is required";
}
for (const redirectUri of client.redirect_uris) {
if (typeof redirectUri !== "string") {
return "Every redirect_uri must be an absolute URL";
}
let parsed: URL;
try {
parsed = new URL(redirectUri);
} catch {
return "Every redirect_uri must be an absolute URL";
}
const isLoopback = LOOPBACK_REDIRECT_HOSTS.has(parsed.hostname);
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback)) {
return "Every redirect_uri must use HTTPS or HTTP on a loopback host";
}
if (parsed.hash || parsed.username || parsed.password) {
return "redirect_uris must not contain fragments or user credentials";
}
}
if (
client.token_endpoint_auth_method !== undefined &&
typeof client.token_endpoint_auth_method !== "string"
) {
return "token_endpoint_auth_method must be a string";
}
const authMethod = client.token_endpoint_auth_method ?? "client_secret_post";
if (!SUPPORTED_CLIENT_AUTH_METHODS.has(authMethod)) {
return `Unsupported token_endpoint_auth_method: ${authMethod}`;
}
if (
client.grant_types !== undefined &&
(!Array.isArray(client.grant_types) ||
!client.grant_types.every((grantType) => typeof grantType === "string"))
) {
return "grant_types must be an array of strings";
}
const grantTypes = client.grant_types ?? ["authorization_code"];
if (
grantTypes.length === 0 ||
!grantTypes.includes("authorization_code") ||
!grantTypes.every((grantType) => SUPPORTED_GRANT_TYPES.has(grantType))
) {
return "grant_types must contain authorization_code and may contain refresh_token";
}
if (
client.response_types !== undefined &&
(!Array.isArray(client.response_types) ||
!client.response_types.every((responseType) => typeof responseType === "string"))
) {
return "response_types must be an array of strings";
}
const responseTypes = client.response_types ?? ["code"];
if (responseTypes.length !== 1 || responseTypes[0] !== "code") {
return "Only the code response_type is supported";
}
if (client.scope !== undefined && typeof client.scope !== "string") {
return "scope must be a string";
}
const scopes = client.scope?.split(/\s+/).filter(Boolean) ?? [];
if (!scopes.every((scope) => OAUTH_SCOPES.includes(scope as (typeof OAUTH_SCOPES)[number]))) {
return "Only the mcp:tools scope is supported";
}
return undefined;
}
function isStoredToken(value: unknown): value is StoredToken {
if (!value || typeof value !== "object") {
return false;
}
const token = value as Partial<StoredToken>;
return (
(token.type === "access" || token.type === "refresh") &&
(token.type === "access" || token.type === "refresh" || token.type === "used_refresh") &&
typeof token.clientId === "string" &&
Array.isArray(token.scopes) &&
token.scopes.every((scope) => typeof scope === "string") &&
typeof token.expiresAt === "number" &&
typeof token.resource === "string"
typeof token.resource === "string" &&
(token.grantId === undefined || typeof token.grantId === "string") &&
(token.type !== "used_refresh" || typeof token.grantId === "string")
);
}
@@ -86,8 +171,10 @@ function parseState(value: string): PersistedOAuthState {
parsed.version !== 1 ||
!parsed.clients ||
typeof parsed.clients !== "object" ||
Array.isArray(parsed.clients) ||
!parsed.tokens ||
typeof parsed.tokens !== "object" ||
Array.isArray(parsed.tokens) ||
!Object.values(parsed.tokens).every(isStoredToken)
) {
throw new Error("Invalid OAuth state file format");
@@ -131,7 +218,6 @@ class PersistentOAuthStore implements OAuthRegisteredClientsStore {
private async persist(): Promise<void> {
const directory = path.dirname(this.stateFile);
await mkdir(directory, { recursive: true, mode: 0o700 });
await chmod(directory, 0o700);
const temporaryFile = `${this.stateFile}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
try {
await writeFile(temporaryFile, `${JSON.stringify(this.state, null, 2)}\n`, {
@@ -149,10 +235,16 @@ class PersistentOAuthStore implements OAuthRegisteredClientsStore {
private async mutate<T>(operation: () => T | Promise<T>): Promise<T> {
await this.ensureLoaded();
const pending = this.mutationQueue.then(async () => {
this.pruneExpired();
const result = await operation();
await this.persist();
return result;
const snapshot = structuredClone(this.state);
try {
this.pruneExpired();
const result = await operation();
await this.persist();
return result;
} catch (error) {
this.state = snapshot;
throw error;
}
});
this.mutationQueue = pending.then(
() => undefined,
@@ -164,7 +256,11 @@ class PersistentOAuthStore implements OAuthRegisteredClientsStore {
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
await this.ensureLoaded();
await this.mutationQueue;
return this.state.clients[clientId];
const client = this.state.clients[clientId];
if (!client || client.client_id !== clientId || clientMetadataProblem(client)) {
return undefined;
}
return client;
}
async registerClient(
@@ -173,26 +269,48 @@ class PersistentOAuthStore implements OAuthRegisteredClientsStore {
const supplied = client as Partial<OAuthClientInformationFull>;
const registered: OAuthClientInformationFull = {
...client,
token_endpoint_auth_method:
client.token_endpoint_auth_method ?? "client_secret_post",
grant_types: client.grant_types ?? ["authorization_code"],
response_types: client.response_types ?? ["code"],
client_id: supplied.client_id || randomUUID(),
client_id_issued_at: supplied.client_id_issued_at || Math.floor(Date.now() / 1000),
};
const problem = clientMetadataProblem(registered);
if (problem) {
throw new InvalidClientMetadataError(problem);
}
return this.mutate(() => {
this.state.clients[registered.client_id] = registered;
return registered;
});
}
async issueTokenPair(clientId: string, scopes: string[], resource: string): Promise<OAuthTokens> {
return this.mutate(() => this.issueTokenPairWithoutPersist(clientId, scopes, resource));
async issueTokenPair(
clientId: string,
scopes: string[],
resource: string,
issueRefreshToken = true,
): Promise<OAuthTokens> {
return this.mutate(() =>
this.issueTokenPairWithoutPersist(
clientId,
scopes,
resource,
randomUUID(),
issueRefreshToken,
),
);
}
private issueTokenPairWithoutPersist(
clientId: string,
scopes: string[],
resource: string,
grantId: string,
issueRefreshToken = true,
): OAuthTokens {
const accessToken = randomToken();
const refreshToken = randomToken();
const now = Date.now();
this.state.tokens[tokenHash(accessToken)] = {
type: "access",
@@ -200,21 +318,38 @@ class PersistentOAuthStore implements OAuthRegisteredClientsStore {
scopes,
expiresAt: now + this.accessTokenTtlSeconds * 1000,
resource,
grantId,
};
this.state.tokens[tokenHash(refreshToken)] = {
type: "refresh",
clientId,
scopes,
expiresAt: now + this.refreshTokenTtlSeconds * 1000,
resource,
};
return {
const tokens: OAuthTokens = {
access_token: accessToken,
token_type: "Bearer",
expires_in: this.accessTokenTtlSeconds,
refresh_token: refreshToken,
scope: scopes.join(" "),
};
if (issueRefreshToken) {
const refreshToken = randomToken();
this.state.tokens[tokenHash(refreshToken)] = {
type: "refresh",
clientId,
scopes,
expiresAt: now + this.refreshTokenTtlSeconds * 1000,
resource,
grantId,
};
tokens.refresh_token = refreshToken;
}
return tokens;
}
private revokeGrantWithoutPersist(grantId: string, preserveReplayEvidence = false): void {
for (const [hash, token] of Object.entries(this.state.tokens)) {
if (
token.grantId === grantId &&
!(preserveReplayEvidence && token.type === "used_refresh")
) {
delete this.state.tokens[hash];
}
}
}
async rotateRefreshToken(
@@ -228,21 +363,28 @@ class PersistentOAuthStore implements OAuthRegisteredClientsStore {
const current = this.state.tokens[hash];
if (
!current ||
current.type !== "refresh" ||
current.clientId !== clientId ||
current.resource !== resource ||
current.expiresAt <= Date.now()
) {
return { status: "invalid" };
}
if (current.type === "used_refresh") {
this.revokeGrantWithoutPersist(current.grantId!, true);
return { status: "invalid" };
}
if (current.type !== "refresh") {
return { status: "invalid" };
}
const scopes = requestedScopes ?? current.scopes;
if (!scopes.every((scope) => current.scopes.includes(scope))) {
return { status: "invalid_scope" };
}
delete this.state.tokens[hash];
const grantId = current.grantId ?? randomUUID();
this.state.tokens[hash] = { ...current, type: "used_refresh", grantId };
return {
status: "ok",
tokens: this.issueTokenPairWithoutPersist(clientId, scopes, resource),
tokens: this.issueTokenPairWithoutPersist(clientId, scopes, resource, grantId),
};
});
}
@@ -254,14 +396,23 @@ class PersistentOAuthStore implements OAuthRegisteredClientsStore {
if (!stored || stored.type !== "access" || stored.expiresAt <= Date.now()) {
return undefined;
}
const client = this.state.clients[stored.clientId];
if (!client || clientMetadataProblem(client)) {
return undefined;
}
return stored;
}
async revoke(token: string, clientId: string): Promise<void> {
await this.mutate(() => {
const hash = tokenHash(token);
if (this.state.tokens[hash]?.clientId === clientId) {
delete this.state.tokens[hash];
const stored = this.state.tokens[hash];
if (stored?.clientId === clientId) {
if (stored.grantId) {
this.revokeGrantWithoutPersist(stored.grantId);
} else {
delete this.state.tokens[hash];
}
}
});
}
@@ -350,7 +501,7 @@ export class RemoteDevOAuthProvider implements OAuthServerProvider {
private readonly authorizationCodes = new Map<string, AuthorizationCodeRecord>();
constructor(private readonly config: AppConfig) {
if (!config.oauthIssuerUrl || !config.oauthResourceUrl || !config.authToken) {
if (!config.oauthIssuerUrl || !config.oauthResourceUrl || !config.oauthApprovalKey) {
throw new Error("OAuth configuration is incomplete");
}
this.issuerUrl = new URL(config.oauthIssuerUrl);
@@ -391,9 +542,11 @@ export class RemoteDevOAuthProvider implements OAuthServerProvider {
params: AuthorizationParams,
response: Response,
): Promise<void> {
if (!(client.grant_types ?? ["authorization_code"]).includes("authorization_code")) {
throw new UnauthorizedClientError("Client is not authorized for authorization_code");
}
const resource = this.validateResource(params.resource);
const scopes = this.validateScopes(params.scopes);
const redirectOrigin = new URL(params.redirectUri).origin;
const request = response.req as Request;
const accessKey =
request.method === "POST" && typeof request.body?.access_key === "string"
@@ -402,12 +555,12 @@ export class RemoteDevOAuthProvider implements OAuthServerProvider {
response.set({
"Content-Security-Policy":
`default-src 'none'; style-src 'unsafe-inline'; form-action 'self' ${redirectOrigin}; base-uri 'none'; frame-ancestors 'none'`,
"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
});
if (!accessKey || !tokensEqual(accessKey, this.config.authToken!)) {
if (!accessKey || !tokensEqual(accessKey, this.config.oauthApprovalKey!)) {
response
.status(accessKey ? 401 : 200)
.type("html")
@@ -431,7 +584,7 @@ export class RemoteDevOAuthProvider implements OAuthServerProvider {
if (params.state !== undefined) {
target.searchParams.set("state", params.state);
}
response.redirect(302, target.href);
response.redirect(303, target.href);
}
async challengeForAuthorizationCode(
@@ -464,7 +617,19 @@ export class RemoteDevOAuthProvider implements OAuthServerProvider {
throw new InvalidGrantError("Invalid authorization code binding");
}
this.authorizationCodes.delete(authorizationCode);
return this.clientsStore.issueTokenPair(client.client_id, record.scopes, record.resource);
try {
return await this.clientsStore.issueTokenPair(
client.client_id,
record.scopes,
record.resource,
client.grant_types?.includes("refresh_token") ?? false,
);
} catch (error) {
if (record.expiresAt > Date.now() && !this.authorizationCodes.has(authorizationCode)) {
this.authorizationCodes.set(authorizationCode, record);
}
throw error;
}
}
async exchangeRefreshToken(
@@ -473,6 +638,9 @@ export class RemoteDevOAuthProvider implements OAuthServerProvider {
scopes?: string[],
resource?: URL,
): Promise<OAuthTokens> {
if (!client.grant_types?.includes("refresh_token")) {
throw new UnauthorizedClientError("Client is not authorized for refresh_token");
}
const resourceValue = this.validateResource(resource);
const requestedScopes = scopes ? this.validateScopes(scopes) : undefined;
const result = await this.clientsStore.rotateRefreshToken(
+147 -10
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { isAscii } from "node:buffer";
import { errorMessage } from "./errors.js";
@@ -25,6 +26,7 @@ interface ManagedProcess {
error: string | undefined;
timedOut: boolean;
chunks: OutputChunk[];
pendingOutput: Record<ProcessOutputStream, Buffer>;
retainedBytes: number;
totalOutputBytes: number;
droppedOutputBytes: number;
@@ -35,6 +37,99 @@ interface ManagedProcess {
cleanup: (() => Promise<void>) | undefined;
}
function isContinuationByte(value: number): boolean {
return value >= 0x80 && value <= 0xbf;
}
function utf8SequenceLengthAt(
data: Buffer,
offset: number,
final: boolean,
): number | undefined {
const first = data[offset]!;
if (first <= 0x7f) {
return 1;
}
let length = 0;
if (first >= 0xc2 && first <= 0xdf) {
length = 2;
} else if (first >= 0xe0 && first <= 0xef) {
length = 3;
} else if (first >= 0xf0 && first <= 0xf4) {
length = 4;
} else {
return 1;
}
if (offset + 1 >= data.length) {
return final ? 1 : undefined;
}
const second = data[offset + 1]!;
if (!isContinuationByte(second)) {
return 1;
}
if ((first === 0xe0 && second < 0xa0) || (first === 0xed && second > 0x9f)) {
return 1;
}
if ((first === 0xf0 && second < 0x90) || (first === 0xf4 && second > 0x8f)) {
return 1;
}
for (let index = 2; index < length; index += 1) {
if (offset + index >= data.length) {
return final ? 1 : undefined;
}
if (!isContinuationByte(data[offset + index]!)) {
return 1;
}
}
return length;
}
function splitOutputChunks(
data: Buffer,
final = false,
): { chunks: Buffer[]; remainder: Buffer } {
if (isAscii(data)) {
const chunks: Buffer[] = [];
for (let offset = 0; offset < data.length; offset += OUTPUT_CHUNK_BYTES) {
chunks.push(Buffer.from(data.subarray(offset, offset + OUTPUT_CHUNK_BYTES)));
}
return { chunks, remainder: Buffer.alloc(0) };
}
const chunks: Buffer[] = [];
let chunkStart = 0;
let cursor = 0;
while (cursor < data.length) {
const sequenceLength = utf8SequenceLengthAt(data, cursor, final);
if (sequenceLength === undefined) {
break;
}
if (
cursor > chunkStart &&
cursor + sequenceLength - chunkStart > OUTPUT_CHUNK_BYTES
) {
chunks.push(Buffer.from(data.subarray(chunkStart, cursor)));
chunkStart = cursor;
continue;
}
cursor += sequenceLength;
if (cursor - chunkStart === OUTPUT_CHUNK_BYTES) {
chunks.push(Buffer.from(data.subarray(chunkStart, cursor)));
chunkStart = cursor;
}
}
if (cursor > chunkStart) {
chunks.push(Buffer.from(data.subarray(chunkStart, cursor)));
}
return {
chunks,
remainder: Buffer.from(data.subarray(cursor)),
};
}
export interface StartProcessRequest {
executable: string;
args: string[];
@@ -113,6 +208,7 @@ export class ProcessManager {
error: undefined,
timedOut: false,
chunks: [],
pendingOutput: { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) },
retainedBytes: 0,
totalOutputBytes: 0,
droppedOutputBytes: 0,
@@ -130,6 +226,9 @@ export class ProcessManager {
child.stderr.on("data", (data: Buffer | string) => {
this.#appendOutput(managed, "stderr", Buffer.from(data));
});
child.stdin.on("error", (error) => {
this.#recordStdinError(managed, error);
});
child.on("error", (error) => {
managed.error = errorMessage(error);
this.#finish(managed, null, null);
@@ -155,7 +254,15 @@ export class ProcessManager {
}
if (request.stdin !== undefined && request.stdin.length > 0) {
child.stdin.write(request.stdin);
try {
child.stdin.write(request.stdin, (error) => {
if (error) {
this.#recordStdinError(managed, error);
}
});
} catch (error) {
this.#recordStdinError(managed, error);
}
}
return sessionId;
}
@@ -387,17 +494,29 @@ export class ProcessManager {
stream: ProcessOutputStream,
data: Buffer,
): void {
for (let offset = 0; offset < data.length; offset += OUTPUT_CHUNK_BYTES) {
const chunkData = Buffer.from(data.subarray(offset, offset + OUTPUT_CHUNK_BYTES));
managed.chunks.push({
seq: managed.nextSeq,
stream,
data: chunkData,
});
managed.totalOutputBytes += data.length;
const pending = managed.pendingOutput[stream];
const combined = pending.length > 0 ? Buffer.concat([pending, data]) : data;
const split = splitOutputChunks(combined);
managed.pendingOutput[stream] = split.remainder;
this.#storeOutputChunks(managed, stream, split.chunks);
this.#trimRetainedOutput(managed);
this.#notify(managed);
}
#storeOutputChunks(
managed: ManagedProcess,
stream: ProcessOutputStream,
chunks: Buffer[],
): void {
for (const data of chunks) {
managed.chunks.push({ seq: managed.nextSeq, stream, data });
managed.nextSeq += 1;
managed.retainedBytes += chunkData.length;
managed.totalOutputBytes += chunkData.length;
managed.retainedBytes += data.length;
}
}
#trimRetainedOutput(managed: ManagedProcess): void {
while (
managed.retainedBytes > this.#options.maxRetainedOutputBytes &&
managed.chunks.length > 0
@@ -408,6 +527,23 @@ export class ProcessManager {
managed.droppedOutputBytes += removed.data.length;
}
}
}
#flushPendingOutput(managed: ManagedProcess): void {
for (const stream of ["stdout", "stderr"] as const) {
const pending = managed.pendingOutput[stream];
if (pending.length === 0) {
continue;
}
const split = splitOutputChunks(pending, true);
managed.pendingOutput[stream] = Buffer.alloc(0);
this.#storeOutputChunks(managed, stream, split.chunks);
}
this.#trimRetainedOutput(managed);
}
#recordStdinError(managed: ManagedProcess, error: unknown): void {
managed.error ??= `stdin write failed: ${errorMessage(error)}`;
this.#notify(managed);
}
@@ -419,6 +555,7 @@ export class ProcessManager {
if (managed.endedAt !== undefined) {
return;
}
this.#flushPendingOutput(managed);
managed.endedAt = Date.now();
managed.exitCode = code;
managed.signal = signal;
+4 -2
View File
@@ -15,10 +15,12 @@ async function main(): Promise<void> {
console.log(`default cwd: ${config.defaultCwd}`);
console.log("execution mode: unrestricted host access");
console.log(
config.allowNoAuth && !config.authToken
config.allowNoAuth && !config.authToken && !config.oauthEnabled
? "authentication: disabled"
: config.oauthEnabled
? "authentication: static bearer + OAuth 2.1 (DCR/PKCE)"
? config.authToken
? "authentication: static bearer + OAuth 2.1 (DCR/PKCE)"
: "authentication: OAuth 2.1 (DCR/PKCE)"
: "authentication: bearer token",
);