fix(api): return 415 when /v1/chat/completions receives non-JSON Content-Type (#6414) (#6513)

fix(api): return 415 on /v1/messages for non-JSON Content-Type via requireJsonContentType middleware (#6414) Integrated into release/v3.8.47. (thanks @chirag127)
This commit is contained in:
Chirag Singhal
2026-07-08 05:06:32 +05:30
committed by GitHub
parent fc8459faa3
commit 1b6a1f08c7
3 changed files with 142 additions and 0 deletions

View File

@@ -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

View File

@@ -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" },
}
);
}

View File

@@ -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"));
});