Skip to content
Merged
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
148 changes: 148 additions & 0 deletions src/app/v1/_lib/codex/__tests__/session-extractor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, expect, test } from "vitest";
import { extractCodexSessionId, isCodexClient } from "../session-extractor";

describe("Codex session extractor", () => {
test("extracts from header session_id", () => {
const headerSessionId = "sess_123456789012345678901";
const result = extractCodexSessionId(
new Headers({ session_id: headerSessionId }),
{
metadata: { session_id: "sess_aaaaaaaaaaaaaaaaaaaaa" },
previous_response_id: "resp_123456789012345678901",
},
"codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64)"
);

expect(result.sessionId).toBe(headerSessionId);
expect(result.source).toBe("header_session_id");
});

test("extracts from header x-session-id", () => {
const headerSessionId = "sess_123456789012345678902";
const result = extractCodexSessionId(
new Headers({ "x-session-id": headerSessionId }),
{
metadata: { session_id: "sess_aaaaaaaaaaaaaaaaaaaaa" },
previous_response_id: "resp_123456789012345678901",
},
"codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64)"
);

expect(result.sessionId).toBe(headerSessionId);
expect(result.source).toBe("header_x_session_id");
});

test("extracts from body metadata.session_id", () => {
const bodySessionId = "sess_123456789012345678903";
const result = extractCodexSessionId(
new Headers(),
{ metadata: { session_id: bodySessionId } },
"codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64)"
);

expect(result.sessionId).toBe(bodySessionId);
expect(result.source).toBe("body_metadata_session_id");
});

test("falls back to previous_response_id", () => {
const previousResponseId = "resp_123456789012345678901";
const result = extractCodexSessionId(
new Headers(),
{ previous_response_id: previousResponseId },
"codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64)"
);

expect(result.sessionId).toBe(`codex_prev_${previousResponseId}`);
expect(result.source).toBe("body_previous_response_id");
});

test("rejects previous_response_id that would exceed 256 after prefix", () => {
const longId = "a".repeat(250); // 250 + 11 (prefix) = 261 > 256
const result = extractCodexSessionId(new Headers(), { previous_response_id: longId }, null);
expect(result.sessionId).toBe(null);
expect(result.source).toBe(null);
});

test("respects extraction priority", () => {
const sessionIdFromHeader = "sess_123456789012345678904";
const xSessionIdFromHeader = "sess_123456789012345678905";
const sessionIdFromBody = "sess_123456789012345678906";
const previousResponseId = "resp_123456789012345678901";

const result = extractCodexSessionId(
new Headers({
session_id: sessionIdFromHeader,
"x-session-id": xSessionIdFromHeader,
}),
{
metadata: { session_id: sessionIdFromBody },
previous_response_id: previousResponseId,
},
"codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64)"
);

expect(result.sessionId).toBe(sessionIdFromHeader);
expect(result.source).toBe("header_session_id");
});

test("detects Codex client User-Agent", () => {
expect(isCodexClient("codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64)")).toBe(true);
expect(isCodexClient("codex_vscode/0.35.0 (Windows 10.0.26100; x86_64)")).toBe(true);
expect(isCodexClient("Mozilla/5.0")).toBe(false);
expect(isCodexClient(null)).toBe(false);
});

test("rejects session_id shorter than 21 characters", () => {
const result = extractCodexSessionId(
new Headers({ session_id: "short_id_12345" }), // 14 chars
{},
null
);
expect(result.sessionId).toBe(null);
expect(result.source).toBe(null);
});

test("accepts session_id with exactly 21 characters (minimum)", () => {
const minId = "a".repeat(21);
const result = extractCodexSessionId(new Headers({ session_id: minId }), {}, null);
expect(result.sessionId).toBe(minId);
expect(result.source).toBe("header_session_id");
});

test("accepts session_id with exactly 256 characters (maximum)", () => {
const maxId = "a".repeat(256);
const result = extractCodexSessionId(new Headers({ session_id: maxId }), {}, null);
expect(result.sessionId).toBe(maxId);
expect(result.source).toBe("header_session_id");
});

test("rejects session_id longer than 256 characters", () => {
const longId = "a".repeat(300);
const result = extractCodexSessionId(new Headers({ session_id: longId }), {}, null);
expect(result.sessionId).toBe(null);
expect(result.source).toBe(null);
});

test("rejects session_id with invalid characters", () => {
// Test with body metadata to avoid Headers normalization
const result = extractCodexSessionId(
new Headers(),
{ metadata: { session_id: "sess_123456789@#$%^&*()!" } },
null
);
expect(result.sessionId).toBe(null);
});

test("accepts session_id with allowed special characters", () => {
const validId = "sess-123_456.789:abc012345";
const result = extractCodexSessionId(new Headers({ session_id: validId }), {}, null);
expect(result.sessionId).toBe(validId);
});

test("returns null when no valid session_id found", () => {
const result = extractCodexSessionId(new Headers(), {}, "codex_cli_rs/0.50.0");
expect(result.sessionId).toBe(null);
expect(result.source).toBe(null);
expect(result.isCodexClient).toBe(true);
});
});
112 changes: 112 additions & 0 deletions src/app/v1/_lib/codex/session-extractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import "server-only";

export type CodexSessionIdSource =
| "header_session_id"
| "header_x_session_id"
| "body_metadata_session_id"
| "body_previous_response_id"
| null;

export interface CodexSessionExtractionResult {
sessionId: string | null;
source: CodexSessionIdSource;
isCodexClient: boolean;
}

// Session ID validation constants
const CODEX_SESSION_ID_MIN_LENGTH = 21; // Codex session_id typically > 20 chars (UUID-like)
const CODEX_SESSION_ID_MAX_LENGTH = 256; // Prevent Redis key bloat from malicious input
const SESSION_ID_PATTERN = /^[\w\-.:]+$/; // Alphanumeric, dash, dot, colon only

// Codex CLI User-Agent pattern (pre-compiled for performance)
const CODEX_CLI_PATTERN = /^(codex_vscode|codex_cli_rs)\/[\d.]+/i;

export function normalizeCodexSessionId(value: unknown): string | null {
if (typeof value !== "string") return null;

const trimmed = value.trim();
if (!trimmed) return null;

if (trimmed.length < CODEX_SESSION_ID_MIN_LENGTH) return null;
if (trimmed.length > CODEX_SESSION_ID_MAX_LENGTH) return null;
if (!SESSION_ID_PATTERN.test(trimmed)) return null;

return trimmed;
}

function parseMetadata(requestBody: Record<string, unknown>): Record<string, unknown> | null {
const metadata = requestBody.metadata;
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
return metadata as Record<string, unknown>;
}

/**
* Detect official Codex CLI clients by User-Agent.
*
* Examples:
* - codex_vscode/0.35.0 (...)
* - codex_cli_rs/0.50.0 (...)
*/
export function isCodexClient(userAgent: string | null): boolean {
if (!userAgent) return false;
return CODEX_CLI_PATTERN.test(userAgent);
}

/**
* Extract Codex session id from headers/body with priority:
* 1) headers["session_id"]
* 2) headers["x-session-id"]
* 3) body.metadata.session_id
* 4) body.previous_response_id (fallback, prefixed with "codex_prev_")
*
* Only accept session ids with length > 20.
*/
export function extractCodexSessionId(
headers: Headers,
requestBody: Record<string, unknown>,
userAgent: string | null
): CodexSessionExtractionResult {
const officialClient = isCodexClient(userAgent);

const headerSessionId = normalizeCodexSessionId(headers.get("session_id"));
if (headerSessionId) {
return {
sessionId: headerSessionId,
source: "header_session_id",
isCodexClient: officialClient,
};
}

const headerXSessionId = normalizeCodexSessionId(headers.get("x-session-id"));
if (headerXSessionId) {
return {
sessionId: headerXSessionId,
source: "header_x_session_id",
isCodexClient: officialClient,
};
}

const metadata = parseMetadata(requestBody);
const bodyMetadataSessionId = metadata ? normalizeCodexSessionId(metadata.session_id) : null;
if (bodyMetadataSessionId) {
return {
sessionId: bodyMetadataSessionId,
source: "body_metadata_session_id",
isCodexClient: officialClient,
};
}

const prevResponseId = normalizeCodexSessionId(requestBody.previous_response_id);
if (prevResponseId) {
const sessionId = `codex_prev_${prevResponseId}`;
if (sessionId.length <= CODEX_SESSION_ID_MAX_LENGTH) {
return {
sessionId,
source: "body_previous_response_id",
isCodexClient: officialClient,
};
}
}

return { sessionId: null, source: null, isCodexClient: officialClient };
}
11 changes: 9 additions & 2 deletions src/app/v1/_lib/converters/codex-to-claude/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import { randomBytes } from "node:crypto";
import { normalizeCodexSessionId } from "@/app/v1/_lib/codex/session-extractor";
import { logger } from "@/lib/logger";

/**
Expand All @@ -26,6 +27,7 @@ import { logger } from "@/lib/logger";
interface ResponseAPIRequest {
model?: string;
instructions?: string;
metadata?: Record<string, unknown>;
input?: Array<{
type?: string;
role?: string;
Expand Down Expand Up @@ -115,7 +117,12 @@ function generateToolCallID(): string {
/**
* 生成用户 ID(基于 account 和 session)
*/
function generateUserID(): string {
function generateUserID(originalMetadata?: Record<string, unknown>): string {
const sessionId = normalizeCodexSessionId(originalMetadata?.session_id);
if (sessionId) {
return `codex_session_${sessionId}`;
}

// 简化实现:使用随机 UUID
const account = randomBytes(16).toString("hex");
const session = randomBytes(16).toString("hex");
Expand Down Expand Up @@ -144,7 +151,7 @@ export function transformCodexRequestToClaude(
max_tokens: 32000,
messages: [],
metadata: {
user_id: generateUserID(),
user_id: generateUserID(req.metadata),
},
stream,
};
Expand Down
7 changes: 5 additions & 2 deletions src/app/v1/_lib/proxy/session-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,11 @@ export class ProxySessionGuard {
try {
// 1. 尝试从客户端提取 session_id(metadata.session_id)
const clientSessionId =
SessionManager.extractClientSessionId(session.request.message) ||
session.generateDeterministicSessionId();
SessionManager.extractClientSessionId(
session.request.message,
session.headers,
session.userAgent
) || session.generateDeterministicSessionId();

// 2. 获取 messages 数组
const messages = session.getMessages();
Expand Down
22 changes: 21 additions & 1 deletion src/lib/session-manager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "server-only";

import crypto from "node:crypto";
import { extractCodexSessionId } from "@/app/v1/_lib/codex/session-extractor";
import { sanitizeHeaders } from "@/app/v1/_lib/proxy/errors";
import { logger } from "@/lib/logger";
import { normalizeRequestSequence } from "@/lib/utils/request-sequence";
Expand Down Expand Up @@ -82,7 +83,26 @@ export class SessionManager {
* 1. metadata.user_id (Claude Code 主要方式,格式: "{user}_session_{sessionId}")
* 2. metadata.session_id (备选方式)
*/
static extractClientSessionId(requestMessage: Record<string, unknown>): string | null {
static extractClientSessionId(
requestMessage: Record<string, unknown>,
headers?: Headers | null,
userAgent?: string | null
): string | null {
// Codex 请求:优先尝试从 headers/body 提取稳定的 session_id
if (headers && Array.isArray(requestMessage.input)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical [SECURITY-VULNERABILITY] Codex session_id validation can be bypassed via fallback

Why this is a problem: At src/lib/session-manager.ts:92, extractCodexSessionId(...) rejects too-short/too-long/invalid-character IDs, but this block falls through when result.sessionId is null and the function can later return metadata.user_id/metadata.session_id without those validations. A crafted metadata.session_id (e.g. extremely long or containing disallowed characters) would then be used as a Redis key component via getOrCreateSessionId(...), defeating the security hardening intent.

Suggested fix:

// Codex/Responses API request: only accept validated session IDs.
// If extraction fails, return null so SessionGuard falls back to deterministic session IDs.
if (headers && Array.isArray(requestMessage.input)) {
  const result = extractCodexSessionId(headers, requestMessage, userAgent ?? null);
  if (result.sessionId) {
    logger.trace("SessionManager: Extracted session from Codex request", {
      sessionId: result.sessionId,
      source: result.source,
      isCodexClient: result.isCodexClient,
    });
    return result.sessionId;
  }

  return null;
}

const result = extractCodexSessionId(headers, requestMessage, userAgent ?? null);
if (result.sessionId) {
logger.trace("SessionManager: Extracted session from Codex request", {
sessionId: result.sessionId,
source: result.source,
isCodexClient: result.isCodexClient,
});
return result.sessionId;
}

return null;
}

const metadata = requestMessage.metadata;
if (!metadata || typeof metadata !== "object") {
return null;
Expand Down
Loading