Skip to content
Closed
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
23 changes: 23 additions & 0 deletions src/adapters/webTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,29 @@ describe('redirect method rewriting', () => {
expect(second.method).toBe('POST');
expect(second.body).toBeTruthy();
});

it('cancels the redirect response body before following the next hop', async () => {
const cancel = vi.fn(async () => {});
let calls = 0;
const f = vi.fn(async () => {
calls += 1;
if (calls === 1) {
return {
status: 302,
ok: false,
statusText: 'Found',
headers: new Headers({ location: 'https://example.com/next' }),
body: { cancel },
arrayBuffer: async () => new ArrayBuffer(0),
} as unknown as Response;
}
return new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } });
});
vi.stubGlobal('fetch', f);
await webFetch('https://example.com/start');
expect(cancel).toHaveBeenCalledOnce();
expect(f).toHaveBeenCalledTimes(2);
});
});

describe('webFetch', () => {
Expand Down
10 changes: 9 additions & 1 deletion src/adapters/webTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,15 @@ async function fetchWithTimeout(
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location');
if (!location) throw new Error('Redirect response has no location');
await response.body?.cancel();
// Release the redirect body before the next hop so undici does not keep
// the prior socket/buffer alive across a chain of Location responses.
if (response.body) {
try {
await response.body.cancel();
} catch {
await response.arrayBuffer().catch(() => undefined);
}
}
const next = new URL(location, current);
if (carriesSensitiveRequestData && next.origin !== initialOrigin) {
throw new Error('Refusing to forward credentials or request body across origins');
Expand Down
1 change: 1 addition & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
runOAuthPkceFlow,
loginAndSaveProfile,
DEFAULT_OPENAI_CLIENT_ID,
isLoopbackRemote,
type OAuthFlowResult,
type OAuthFlowOptions,
} from './oauthPkce.js';
Expand Down
27 changes: 27 additions & 0 deletions src/auth/oauthPkce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { isLoopbackRemote } from './oauthPkce.js';

/**
* The OpenAI redirect_uri is always the advertised localhost form. Callbacks may
* still arrive over IPv4 127.0.0.1 or IPv6 ::1 on the dual-stack listener.
*/
describe('isLoopbackRemote (OpenAI PKCE callback)', () => {
const advertisedRedirectUri = (port = 1455) => `http://localhost:${port}/auth/callback`;

it('accepts IPv4 and IPv6 localhost remotes against the advertised redirect URI', () => {
expect(advertisedRedirectUri()).toBe('http://localhost:1455/auth/callback');
expect(isLoopbackRemote('127.0.0.1')).toBe(true);
expect(isLoopbackRemote('::1')).toBe(true);
expect(isLoopbackRemote('::ffff:127.0.0.1')).toBe(true);
});

it('rejects non-loopback remotes that must never drive the callback', () => {
expect(isLoopbackRemote(undefined)).toBe(false);
expect(isLoopbackRemote('')).toBe(false);
expect(isLoopbackRemote('10.0.0.1')).toBe(false);
expect(isLoopbackRemote('192.168.1.1')).toBe(false);
expect(isLoopbackRemote('8.8.8.8')).toBe(false);
expect(isLoopbackRemote('fe80::1')).toBe(false);
expect(isLoopbackRemote('2001:db8::1')).toBe(false);
});
});
31 changes: 29 additions & 2 deletions src/auth/oauthPkce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// ============================================

import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { isIP } from 'node:net';
import { randomBytes, createHash } from 'node:crypto';
import { AuthProfileStore, type AuthProfile } from './oauthStore.js';
import { openBrowser } from './openBrowser.js';
Expand All @@ -26,6 +27,26 @@ const PROFILE_KEY = 'openai-gpt:default';
export const DEFAULT_OPENAI_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const OAUTH_ORIGINATOR = 'openswarm';

/**
* Accept only the advertised loopback callback forms: IPv4 127.0.0.1 and IPv6
* ::1 (including the IPv4-mapped ::ffff:127.0.0.1 form Node reports when a
* dual-stack socket receives an IPv4 connection). Any other remote address is
* rejected so the callback server cannot be driven from off-host.
*
* The OpenAI redirect_uri stays `http://localhost:<port>/auth/callback` (the
* registered client value); browsers may still connect via ::1 or 127.0.0.1.
*/
export function isLoopbackRemote(address: string | undefined): boolean {
if (!address) return false;
const family = isIP(address);
if (family === 4) return address === '127.0.0.1';
if (family === 6) {
const lower = address.toLowerCase();
return lower === '::1' || lower === '::ffff:127.0.0.1';
}
return false;
}

// PKCE helpers

function generateCodeVerifier(): string {
Expand Down Expand Up @@ -113,6 +134,12 @@ export async function runOAuthPkceFlow(options: OAuthFlowOptions = {}): Promise<
return;
}

if (!isLoopbackRemote(req.socket.remoteAddress)) {
res.writeHead(403);
res.end('Forbidden');
return;
}

const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);

if (url.pathname !== '/auth/callback') {
Expand Down Expand Up @@ -236,8 +263,8 @@ export async function runOAuthPkceFlow(options: OAuthFlowOptions = {}): Promise<
}
});

server.listen(port, '127.0.0.1', () => {
console.log(`[Auth] Callback server listening on http://127.0.0.1:${port}`);
server.listen(port, 'localhost', () => {
console.log(`[Auth] Callback server listening on http://localhost:${port} (IPv4 + IPv6 loopback)`);
console.log(`[Auth] 브라우저에서 OpenAI 로그인 페이지를 엽니다...`);
openBrowser(authUrl);
});
Expand Down
63 changes: 62 additions & 1 deletion src/issues/graphql/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,74 @@
import { afterEach, describe, expect, it } from 'vitest';
import { createServer, type IncomingMessage } from 'node:http';
import { handleGraphQL, isGraphQLTransportAuthorized } from './server.js';
import {
createGraphQLCostRule,
handleGraphQL,
isGraphQLRequest,
isGraphQLTransportAuthorized,
} from './server.js';
import { validate, buildSchema, parse } from 'graphql';

function request(address: string | undefined, headers: Record<string, string> = {}): IncomingMessage {
return { socket: { remoteAddress: address }, headers } as unknown as IncomingMessage;
}

afterEach(() => { delete process.env.OPENSWARM_GRAPHQL_TOKEN; });

describe('isGraphQLRequest exact endpoint matching', () => {
it('accepts only the exact /graphql path (with or without query/hash)', () => {
expect(isGraphQLRequest('/graphql')).toBe(true);
expect(isGraphQLRequest('/graphql?query={__typename}')).toBe(true);
expect(isGraphQLRequest('/graphql#section')).toBe(true);
});

it('rejects path-prefix lookalikes that previously matched startsWith', () => {
expect(isGraphQLRequest('/graphql/')).toBe(false);
expect(isGraphQLRequest('/graphql/admin')).toBe(false);
expect(isGraphQLRequest('/graphqlfoo')).toBe(false);
expect(isGraphQLRequest('/api/graphql')).toBe(false);
expect(isGraphQLRequest(undefined)).toBe(false);
expect(isGraphQLRequest('')).toBe(false);
});
});

describe('GraphQL query cost limits', () => {
const schema = buildSchema(`
type Query {
a: Query
b: String
}
`);

it('rejects a document that exceeds max depth', () => {
const query = '{ a { a { a { a { b } } } } }';
const errors = validate(schema, parse(query), [createGraphQLCostRule({ maxDepth: 3 })]);
expect(errors.some((e) => /maximum depth/i.test(e.message))).toBe(true);
});

it('rejects a document that exceeds field or alias counts', () => {
const query = '{ x: b y: b z: b }';
const errors = validate(schema, parse(query), [
createGraphQLCostRule({ maxFieldCount: 2, maxAliasCount: 2, maxCost: 100 }),
]);
expect(errors.some((e) => /maximum (field|alias) count/i.test(e.message))).toBe(true);
});

it('rejects a document that exceeds weighted execution cost', () => {
// cost accumulates as Σ depth at each field enter — a wide shallow fan-out
// of depth-1 fields exceeds a tight budget without tripping depth/alias caps.
const query = '{ a1: b a2: b a3: b a4: b }';
const errors = validate(schema, parse(query), [
createGraphQLCostRule({ maxDepth: 10, maxFieldCount: 20, maxAliasCount: 20, maxCost: 3 }),
]);
expect(errors.some((e) => /maximum execution cost/i.test(e.message))).toBe(true);
});

it('allows a shallow bounded query', () => {
const errors = validate(schema, parse('{ b }'), [createGraphQLCostRule()]);
expect(errors).toEqual([]);
});
});

describe('GraphQL transport authorization', () => {
it('allows a proven loopback transport without trusting Origin', () => {
expect(isGraphQLTransportAuthorized(request('127.0.0.1'))).toBe(true);
Expand Down
94 changes: 92 additions & 2 deletions src/issues/graphql/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
// ============================================

import { createSchema, createYoga } from 'graphql-yoga';
import {
GraphQLError,
type ASTVisitor,
type ValidationContext,
type ValidationRule,
} from 'graphql';
import { typeDefs } from './typeDefs.js';
import { resolvers } from './resolvers.js';
import { registryTypeDefs } from '../../registry/graphql/typeDefs.js';
Expand All @@ -15,6 +21,12 @@ import { timingSafeEqual } from 'node:crypto';
const CORS_METHODS = 'GET, POST, OPTIONS';
const CORS_HEADERS = 'Content-Type, Authorization, X-OpenSwarm-GraphQL-Token';

/** Depth / fan-out / alias / weighted-cost caps for untrusted GraphQL documents. */
export const GRAPHQL_MAX_DEPTH = 12;
export const GRAPHQL_MAX_FIELD_COUNT = 120;
export const GRAPHQL_MAX_ALIAS_COUNT = 40;
export const GRAPHQL_MAX_COST = 250;

function isAllowedOrigin(origin: string): boolean {
let url: URL;
try {
Expand Down Expand Up @@ -97,6 +109,75 @@ export function isGraphQLTransportAuthorized(req: IncomingMessage): boolean {
return hasValidToken({ authorization, token });
}

/**
* Reject documents that nest, fan out, or alias themselves into an unbounded
* execution cost before resolvers run.
*/
export function createGraphQLCostRule(limits: {
maxDepth?: number;
maxFieldCount?: number;
maxAliasCount?: number;
maxCost?: number;
} = {}): ValidationRule {
const maxDepth = limits.maxDepth ?? GRAPHQL_MAX_DEPTH;
const maxFieldCount = limits.maxFieldCount ?? GRAPHQL_MAX_FIELD_COUNT;
const maxAliasCount = limits.maxAliasCount ?? GRAPHQL_MAX_ALIAS_COUNT;
const maxCost = limits.maxCost ?? GRAPHQL_MAX_COST;

return function GraphQLCostRule(context: ValidationContext): ASTVisitor {
let depth = 0;
let fieldCount = 0;
let aliasCount = 0;
let cost = 0;

return {
Field: {
enter(node) {
depth += 1;
fieldCount += 1;
if (node.alias) aliasCount += 1;
// Deeper fields cost more so nested selection sets hit the budget first.
cost += depth;

if (depth > maxDepth) {
context.reportError(new GraphQLError(
`Query exceeds maximum depth of ${maxDepth}`,
{ nodes: [node] },
));
}
if (fieldCount > maxFieldCount) {
context.reportError(new GraphQLError(
`Query exceeds maximum field count of ${maxFieldCount}`,
{ nodes: [node] },
));
}
if (aliasCount > maxAliasCount) {
context.reportError(new GraphQLError(
`Query exceeds maximum alias count of ${maxAliasCount}`,
{ nodes: [node] },
));
}
if (cost > maxCost) {
context.reportError(new GraphQLError(
`Query exceeds maximum execution cost of ${maxCost}`,
{ nodes: [node] },
));
}
},
leave() {
depth -= 1;
},
},
// Fragment fields are counted when the fragment definition itself is walked.
FragmentDefinition: {
enter() {
depth = 0;
},
},
};
};
}

// GraphQL Yoga 인스턴스 생성 (이슈 + 코드 레지스트리 스키마 머지)
const yoga = createYoga({
schema: createSchema({
Expand All @@ -111,6 +192,13 @@ const yoga = createYoga({
warn: (...args: any[]) => console.warn('[GraphQL]', ...args),
error: (...args: any[]) => console.error('[GraphQL]', ...args),
},
plugins: [
{
onValidate({ addValidationRule }) {
addValidationRule(createGraphQLCostRule());
},
},
],
});

/**
Expand All @@ -134,11 +222,13 @@ export async function handleGraphQL(
}

/**
* GraphQL 경로 매칭 여부
* Exact GraphQL endpoint match — path-prefix forms like `/graphql/admin` or
* `/graphqlfoo` must not reach the Yoga handler.
*/
export function isGraphQLRequest(url: string | undefined): boolean {
if (!url) return false;
return url.startsWith('/graphql');
const path = url.split(/[?#]/, 1)[0] ?? '';
return path === '/graphql';
}

export { yoga };
28 changes: 28 additions & 0 deletions src/mcp/mcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
initMcpTools,
callMcpTool,
withDeadline,
sanitizeInputSchema,
} from './mcpClient.js';
import type { ToolDefinition } from '../adapters/tools.js';

Expand Down Expand Up @@ -416,3 +417,30 @@ describe('withDeadline', () => {
vi.useRealTimers();
});
});

describe('sanitizeInputSchema bounds', () => {
it('keeps a small object schema', () => {
const schema = { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] };
expect(sanitizeInputSchema(schema)).toEqual(schema);
});

it('replaces oversized serialized schemas with the empty object schema', () => {
const properties: Record<string, unknown> = {};
for (let i = 0; i < 40; i++) {
properties[`field_${i}`] = { type: 'string', description: 'x'.repeat(500) };
}
const out = sanitizeInputSchema({ type: 'object', properties });
expect(out).toEqual({ type: 'object', properties: {} });
});

it('replaces schemas with too many properties', () => {
const properties: Record<string, unknown> = {};
for (let i = 0; i < 80; i++) properties[`p${i}`] = { type: 'string' };
expect(sanitizeInputSchema({ type: 'object', properties })).toEqual({ type: 'object', properties: {} });
});

it('rejects non-object root types', () => {
expect(sanitizeInputSchema({ type: 'string' })).toEqual({ type: 'object', properties: {} });
expect(sanitizeInputSchema(null)).toEqual({ type: 'object', properties: {} });
});
});
Loading