diff --git a/src/app/api/v1/messages/route.ts b/src/app/api/v1/messages/route.ts index 9a67080aa8..3e2172b1eb 100644 --- a/src/app/api/v1/messages/route.ts +++ b/src/app/api/v1/messages/route.ts @@ -1,6 +1,7 @@ import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; +import { requireJsonContentType } from "@/shared/middleware/requireJsonContentType"; import { withEarlyStreamKeepalive, ANTHROPIC_PING_FRAME, @@ -39,6 +40,11 @@ export async function OPTIONS() { * parsed at most once per request. */ async function postHandler(request: any, context: any, preParsedBody: any = null) { + // Reject non-JSON Content-Type with 415 before touching the body — mirrors OpenAI's + // reference API and matches /v1/chat/completions (#6414). + const ctRejection = requireJsonContentType(request); + if (ctRejection) return ctRejection; + await ensureInitialized(); // Streaming Anthropic clients (Claude Code, the Anthropic SDK) drop the connection // when no bytes arrive while a large prompt is processed before the first token — a diff --git a/src/shared/middleware/requireJsonContentType.ts b/src/shared/middleware/requireJsonContentType.ts new file mode 100644 index 0000000000..cf3f937f3e --- /dev/null +++ b/src/shared/middleware/requireJsonContentType.ts @@ -0,0 +1,47 @@ +/** + * Content-Type guard for chat/message routes (#6414). + * + * OpenAI's reference API returns HTTP 415 `unsupported_media_type` when a POST to + * /v1/chat/completions arrives with a non-JSON Content-Type (or none). OmniRoute + * previously admitted such requests, silently parsed the body as JSON (via + * `request.clone().json().catch(() => null)`), and let them fall through to the + * provider-lookup layer — where they emerged as a misleading `model_not_found` / + * generic error rather than the RFC 7231 §6.5.13-mandated 415. This guard closes + * that gap at the route boundary, mirroring the pre-parse pattern already used by + * `chatBodyAdmission.ts`. + * + * Returns a ready 415 Response when the request must be rejected, or `null` to + * proceed. Only inspects the header — no body read, no I/O. + * + * @module shared/middleware/requireJsonContentType + */ + +import { CORS_HEADERS } from "../utils/cors"; + +/** + * Route-level guard. Rejects POST/PUT/PATCH requests whose `Content-Type` is not + * `application/json` (a `; charset=…` suffix is permitted). A missing header is + * treated as unsupported, matching the OpenAI reference behavior cited in #6414. + */ +export function requireJsonContentType(request: Request): Response | null { + const method = request.method.toUpperCase(); + if (method !== "POST" && method !== "PUT" && method !== "PATCH") return null; + + const raw = request.headers.get("content-type"); + const ct = (raw ?? "").trim().toLowerCase(); + if (ct.startsWith("application/json")) return null; + + return new Response( + JSON.stringify({ + error: { + message: "Content-Type must be application/json", + type: "invalid_request_error", + code: "unsupported_media_type", + }, + }), + { + status: 415, + headers: { ...CORS_HEADERS, "Content-Type": "application/json" }, + } + ); +} diff --git a/tests/unit/chat-content-type-6414.test.ts b/tests/unit/chat-content-type-6414.test.ts new file mode 100644 index 0000000000..aa49986024 --- /dev/null +++ b/tests/unit/chat-content-type-6414.test.ts @@ -0,0 +1,89 @@ +// #6414: POST /v1/chat/completions (and /v1/messages) must return HTTP 415 +// `unsupported_media_type` when the Content-Type header is not application/json, +// matching OpenAI's reference API and RFC 7231 §6.5.13. Previously OmniRoute +// silently parsed such requests as JSON via `.clone().json().catch(() => null)` +// and let them reach the provider-lookup layer, where they surfaced as misleading +// `model_not_found` / generic errors instead of a boundary 415. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { requireJsonContentType } = await import( + "../../src/shared/middleware/requireJsonContentType.ts" +); + +function makeRequest(method: string, contentType: string | null): Request { + const headers = new Headers(); + if (contentType !== null) headers.set("content-type", contentType); + return new Request("http://localhost/v1/chat/completions", { + method, + headers, + body: method === "GET" || method === "HEAD" ? undefined : "{}", + }); +} + +test("returns 415 for text/plain on POST", async () => { + const rejection = requireJsonContentType(makeRequest("POST", "text/plain")); + assert.ok(rejection, "expected a rejection Response"); + assert.equal(rejection.status, 415); + const body = await rejection.json(); + assert.equal(body.error.code, "unsupported_media_type"); + assert.equal(body.error.type, "invalid_request_error"); +}); + +test("returns 415 when Content-Type header is missing on POST", async () => { + const rejection = requireJsonContentType(makeRequest("POST", null)); + assert.ok(rejection, "missing content-type must be rejected"); + assert.equal(rejection.status, 415); +}); + +test("returns 415 for application/x-www-form-urlencoded on POST", async () => { + const rejection = requireJsonContentType( + makeRequest("POST", "application/x-www-form-urlencoded") + ); + assert.ok(rejection); + assert.equal(rejection.status, 415); +}); + +test("admits application/json", () => { + assert.equal(requireJsonContentType(makeRequest("POST", "application/json")), null); +}); + +test("admits application/json with charset suffix", () => { + assert.equal( + requireJsonContentType(makeRequest("POST", "application/json; charset=utf-8")), + null + ); +}); + +test("admits Application/JSON (case-insensitive)", () => { + assert.equal(requireJsonContentType(makeRequest("POST", "Application/JSON")), null); +}); + +test("admits application/json with leading whitespace", () => { + assert.equal(requireJsonContentType(makeRequest("POST", " application/json")), null); +}); + +test("does not touch GET requests (no body → nothing to reject)", () => { + assert.equal(requireJsonContentType(makeRequest("GET", "text/plain")), null); +}); + +test("does not touch OPTIONS preflight", () => { + assert.equal(requireJsonContentType(makeRequest("OPTIONS", null)), null); +}); + +test("guards PUT and PATCH the same as POST", () => { + const putRejection = requireJsonContentType(makeRequest("PUT", "text/plain")); + assert.ok(putRejection); + assert.equal(putRejection.status, 415); + + const patchRejection = requireJsonContentType(makeRequest("PATCH", "text/plain")); + assert.ok(patchRejection); + assert.equal(patchRejection.status, 415); +}); + +test("rejection carries CORS + JSON headers", () => { + const rejection = requireJsonContentType(makeRequest("POST", "text/plain")); + assert.ok(rejection); + assert.equal(rejection.headers.get("Content-Type"), "application/json"); + assert.ok(rejection.headers.get("Access-Control-Allow-Methods")); +});