From ddbe941b275c5297bf209fa9877590fc6c6e7cc1 Mon Sep 17 00:00:00 2001 From: pacocartones Date: Fri, 16 Oct 2026 20:39:47 +0000 Subject: [PATCH 1/2] fix(api): answer 400 for malformed JSON on POST /api/plugins and PATCH /api/relay/tokens/[id] --- .../fixes/PENDING-plugins-relay-json-400.md | 1 + src/app/api/plugins/route.ts | 10 +- src/app/api/relay/tokens/[id]/route.ts | 7 +- ...i-malformed-json-400-plugins-relay.test.ts | 111 ++++++++++++++++++ 4 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/PENDING-plugins-relay-json-400.md create mode 100644 tests/unit/api-malformed-json-400-plugins-relay.test.ts diff --git a/changelog.d/fixes/PENDING-plugins-relay-json-400.md b/changelog.d/fixes/PENDING-plugins-relay-json-400.md new file mode 100644 index 000000000000..dc24cc3d3c15 --- /dev/null +++ b/changelog.d/fixes/PENDING-plugins-relay-json-400.md @@ -0,0 +1 @@ +- **fix(api):** `POST /api/plugins` and `PATCH /api/relay/tokens/[id]` called `request.json()` with no guard, so a body that is not JSON made the handler reject instead of answering; both now answer 400 with `Invalid JSON body`. diff --git a/src/app/api/plugins/route.ts b/src/app/api/plugins/route.ts index 22f742bed322..069595c3e4e5 100644 --- a/src/app/api/plugins/route.ts +++ b/src/app/api/plugins/route.ts @@ -45,7 +45,15 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { const authError = await requireManagementAuth(request); if (authError) return authError; - const body = await request.json(); + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400, headers: CORS_HEADERS } + ); + } const schema = z.object({ path: z.string().min(1).regex(/^\/[^]*$/, "Path must be absolute").refine( (p) => !p.includes("\0") && !p.includes(".."), diff --git a/src/app/api/relay/tokens/[id]/route.ts b/src/app/api/relay/tokens/[id]/route.ts index e2add2ec40e9..fe8765073659 100644 --- a/src/app/api/relay/tokens/[id]/route.ts +++ b/src/app/api/relay/tokens/[id]/route.ts @@ -52,7 +52,12 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id const authError = await requireManagementAuth(request, { alwaysRequireAuth: true }); if (authError) return authError; const { id } = await params; - const rawBody = await request.json(); + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } const validation = validateBody(relayTokenPatchSchema, rawBody); if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); diff --git a/tests/unit/api-malformed-json-400-plugins-relay.test.ts b/tests/unit/api-malformed-json-400-plugins-relay.test.ts new file mode 100644 index 000000000000..69758895998d --- /dev/null +++ b/tests/unit/api-malformed-json-400-plugins-relay.test.ts @@ -0,0 +1,111 @@ +/** + * Regression tests: POST /api/plugins and PATCH /api/relay/tokens/[id] must return 400 + * (not an unhandled error) on a malformed JSON request body. + * + * Before the fix both handlers called `await request.json()` with no try/catch, so a + * body that is not JSON made the handler reject instead of answering. A malformed body + * must surface as a clean 400, while a well-formed body keeps its existing behavior. + * + * DB setup mirrors tests/unit/api-malformed-json-400.test.ts: a temp DATA_DIR with no + * configured password, so POST /api/plugins needs no login. PATCH /api/relay/tokens/[id] + * always requires management auth, so its requests carry a manage-scoped API key, as in + * tests/unit/relay-tokens-always-protected.test.ts, and target a seeded token. DB handles + * are released in test.after (resetDbInstance), as unreleased SQLite handles hang the runner. + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-malformed-json-plugins-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET; +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "malformed-json-plugins-test-secret"; +delete process.env.INITIAL_PASSWORD; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const relayDb = await import("../../src/lib/db/relayProxies.ts"); +const pluginsRoute = await import("../../src/app/api/plugins/route.ts"); +const relayTokenRoute = await import("../../src/app/api/relay/tokens/[id]/route.ts"); + +function jsonRequest(url: string, method: string, body: string): Request { + return new Request(url, { + method, + headers: { "content-type": "application/json" }, + body, + }); +} + +const manageKey = await apiKeysDb.createApiKey("manage-malformed-json", "machine-relay", [ + "manage", +]); +const relayTokenId = relayDb.createRelayToken({ name: "malformed-json-target" }).id; +const relayUrl = `http://localhost/api/relay/tokens/${relayTokenId}`; +const relayParams = () => ({ params: Promise.resolve({ id: relayTokenId }) }); + +function managedJsonRequest(url: string, method: string, body: string): Request { + const request = jsonRequest(url, method, body); + request.headers.set("authorization", `Bearer ${manageKey.key}`); + return request; +} + +test.after(() => { + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + if (ORIGINAL_API_KEY_SECRET === undefined) delete process.env.API_KEY_SECRET; + else process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET; + if (ORIGINAL_INITIAL_PASSWORD !== undefined) + process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; +}); + +test("POST /api/plugins — malformed JSON body returns 400", async () => { + const res = await pluginsRoute.POST( + jsonRequest("http://localhost/api/plugins", "POST", "not-json") as never + ); + assert.equal(res.status, 400); +}); + +test("POST /api/plugins — well-formed but invalid body still returns 400", async () => { + const res = await pluginsRoute.POST( + jsonRequest("http://localhost/api/plugins", "POST", JSON.stringify({})) as never + ); + assert.equal(res.status, 400); + const body = (await res.json()) as { error?: string }; + assert.equal(body.error, "Invalid request"); +}); + +test("PATCH /api/relay/tokens/[id] — malformed JSON body returns 400", async () => { + const res = await relayTokenRoute.PATCH( + managedJsonRequest(relayUrl, "PATCH", "not-json") as never, + relayParams() + ); + assert.equal(res.status, 400); + const body = (await res.json()) as { error?: string }; + assert.equal(body.error, "Invalid JSON body"); + assert.equal(relayDb.getRelayToken(relayTokenId)?.name, "malformed-json-target"); +}); + +test("PATCH /api/relay/tokens/[id] — well-formed but empty update still returns 400", async () => { + const res = await relayTokenRoute.PATCH( + managedJsonRequest(relayUrl, "PATCH", JSON.stringify({})) as never, + relayParams() + ); + assert.equal(res.status, 400); +}); + +test("PATCH /api/relay/tokens/[id] — malformed JSON without credentials is still 401", async () => { + const res = await relayTokenRoute.PATCH( + jsonRequest(relayUrl, "PATCH", "not-json") as never, + relayParams() + ); + assert.equal(res.status, 401); +}); From 71b0b73ba8ba8cf7b3c2fc1a895872a409424d62 Mon Sep 17 00:00:00 2001 From: pacocartones Date: Sun, 18 Oct 2026 02:38:57 +0000 Subject: [PATCH 2/2] chore(changelog): number the fixes fragment for #17010 --- ...lugins-relay-json-400.md => 17010-plugins-relay-json-400.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/fixes/{PENDING-plugins-relay-json-400.md => 17010-plugins-relay-json-400.md} (60%) diff --git a/changelog.d/fixes/PENDING-plugins-relay-json-400.md b/changelog.d/fixes/17010-plugins-relay-json-400.md similarity index 60% rename from changelog.d/fixes/PENDING-plugins-relay-json-400.md rename to changelog.d/fixes/17010-plugins-relay-json-400.md index dc24cc3d3c15..cf81fec30974 100644 --- a/changelog.d/fixes/PENDING-plugins-relay-json-400.md +++ b/changelog.d/fixes/17010-plugins-relay-json-400.md @@ -1 +1 @@ -- **fix(api):** `POST /api/plugins` and `PATCH /api/relay/tokens/[id]` called `request.json()` with no guard, so a body that is not JSON made the handler reject instead of answering; both now answer 400 with `Invalid JSON body`. +- **fix(api):** `POST /api/plugins` and `PATCH /api/relay/tokens/[id]` called `request.json()` with no guard, so a body that is not JSON made the handler reject instead of answering; both now answer 400 with `Invalid JSON body` ([#17010](https://github.com/diegosouzapw/OmniRoute/pull/17010)) — thanks @pacocartones