From ce0edc3a291a42a2acbcc15678038355600e6f56 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 14:40:45 -0500 Subject: [PATCH 01/43] First pass at handling bad Content-Type headers. Focus on a way to do it that doesn't have to touch man files in the api/ directory. --- app.js | 2 +- rest.js | 51 +++++++++++- routes/__tests__/contentType.test.js | 113 +++++++++++++++++++++++++++ routes/api-routes.js | 3 + 4 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 routes/__tests__/contentType.test.js diff --git a/app.js b/app.js index fa6e7900..9f416f3c 100644 --- a/app.js +++ b/app.js @@ -57,7 +57,7 @@ app.use( }) ) app.use(logger('dev')) -app.use(express.json()) +app.use(express.json({ type: ["application/json", "application/ld+json"] })) app.use(express.text()) app.use(express.urlencoded({ extended: true })) app.use(cookieParser()) diff --git a/rest.js b/rest.js index 00e61700..40c7a675 100644 --- a/rest.js +++ b/rest.js @@ -24,6 +24,52 @@ const checkPatchOverrideSupport = function (req, res) { return undefined !== override && override === "PATCH" } +/** + * Middleware to validate Content-Type headers on API write endpoints. + * Ensures that requests carrying bodies have an appropriate Content-Type before + * reaching controllers, preventing unhandled errors from unparsed or mis-parsed bodies. + * + * - Skips validation for methods that don't carry bodies (GET, HEAD, OPTIONS, DELETE) + * - Allows text/plain for /search endpoints (which accept plain text search terms) + * - Requires application/json or application/ld+json for all other write endpoints + * - Returns 400 for missing Content-Type, 415 for unsupported Content-Type + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {Function} next - Express next middleware function + */ +const validateContentType = function (req, res, next) { + const skipMethods = ["GET", "HEAD", "OPTIONS", "DELETE"] + if (skipMethods.includes(req.method)) { + return next() + } + + const contentType = req.get("Content-Type") ?? "" + + if (!contentType) { + res.statusMessage = `Missing Content-Type header. Requests to this endpoint require a Content-Type of "application/json" or "application/ld+json".` + res.status(415) + return next(res) + } + + const isJson = contentType.includes("application/json") || contentType.includes("application/ld+json") + const isText = contentType.includes("text/plain") + const isSearchEndpoint = req.path.startsWith("/search") + + if (isJson) { + return next() + } + + if (isText && isSearchEndpoint) { + return next() + } + + const acceptedTypes = `"application/json" or "application/ld+json"${isSearchEndpoint ? ' or "text/plain"' : ''}` + res.statusMessage = `Unsupported Content-Type: "${contentType}". This endpoint requires ${acceptedTypes}.` + res.status(415) + next(res) +} + /** * Throughout the routes are certain warning, error, and hard fail scenarios. * REST is all about communication. The response code and the textual body are particular. @@ -95,6 +141,9 @@ The requested web page or resource could not be found.` case 409: // These are all handled in db-controller.js already. break + case 415: + // Unsupported Media Type. The Content-Type header is not acceptable for this endpoint. + break case 501: // Not implemented. Handled upstream. break @@ -113,4 +162,4 @@ It may not have completed at all, and most likely did not complete successfully. res.status(error.status).send(error.message) } -export default { checkPatchOverrideSupport, messenger } +export default { checkPatchOverrideSupport, validateContentType, messenger } diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js new file mode 100644 index 00000000..b846cf6e --- /dev/null +++ b/routes/__tests__/contentType.test.js @@ -0,0 +1,113 @@ +import { jest } from "@jest/globals" +import express from "express" +import request from "supertest" +import rest from '../../rest.js' + +// Set up a minimal Express app with the Content-Type validation middleware +const routeTester = new express() +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) +routeTester.use(express.text()) +routeTester.use(express.urlencoded({ extended: false })) + +// Mount the validateContentType middleware on /api just like api-routes.js +routeTester.use("/api", rest.validateContentType) + +// Simple JSON-only endpoint (like /api/create, /api/query, etc.) +routeTester.post("/api/create", (req, res) => { + res.status(200).json({ received: req.body }) +}) + +// Search endpoint that accepts text/plain +routeTester.post("/api/search", (req, res) => { + res.status(200).json({ received: req.body }) +}) + +// GET endpoint should pass through without Content-Type validation +routeTester.get("/api/info", (req, res) => { + res.status(200).json({ info: true }) +}) + +// Error handler matching the app's pattern +routeTester.use(rest.messenger) + +describe("Content-Type validation middleware", () => { + + it("accepts application/json with valid JSON body", async () => { + const response = await request(routeTester) + .post("/api/create") + .set("Content-Type", "application/json") + .send({ test: "data" }) + expect(response.statusCode).toBe(200) + expect(response.body.received.test).toBe("data") + }) + + it("accepts application/ld+json with valid JSON body", async () => { + const response = await request(routeTester) + .post("/api/create") + .set("Content-Type", "application/ld+json") + .send(JSON.stringify({ "@context": "http://example.org", test: "ld" })) + expect(response.statusCode).toBe(200) + expect(response.body.received["@context"]).toBe("http://example.org") + }) + + it("accepts application/json with charset parameter", async () => { + const response = await request(routeTester) + .post("/api/create") + .set("Content-Type", "application/json; charset=utf-8") + .send({ test: "charset" }) + expect(response.statusCode).toBe(200) + }) + + it("returns 415 for missing Content-Type header", async () => { + const response = await request(routeTester) + .post("/api/create") + .unset("Content-Type") + .send(Buffer.from('{"test":"data"}')) + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Missing Content-Type header") + }) + + it("returns 415 for text/plain on JSON-only endpoint", async () => { + const response = await request(routeTester) + .post("/api/create") + .set("Content-Type", "text/plain") + .send("some plain text") + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Unsupported Content-Type") + }) + + it("returns 415 for application/xml", async () => { + const response = await request(routeTester) + .post("/api/create") + .set("Content-Type", "application/xml") + .send("") + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Unsupported Content-Type") + }) + + it("allows text/plain on search endpoint", async () => { + const response = await request(routeTester) + .post("/api/search") + .set("Content-Type", "text/plain") + .send("search terms") + expect(response.statusCode).toBe(200) + expect(response.body.received).toBe("search terms") + }) + + it("allows application/json on search endpoint", async () => { + const response = await request(routeTester) + .post("/api/search") + .set("Content-Type", "application/json") + .send({ searchText: "hello" }) + expect(response.statusCode).toBe(200) + expect(response.body.received.searchText).toBe("hello") + }) + + it("skips validation for GET requests", async () => { + const response = await request(routeTester) + .get("/api/info") + expect(response.statusCode).toBe(200) + expect(response.body.info).toBe(true) + }) + +}) diff --git a/routes/api-routes.js b/routes/api-routes.js index e5cdc743..c92730a6 100644 --- a/routes/api-routes.js +++ b/routes/api-routes.js @@ -44,10 +44,13 @@ import releaseRouter from './release.js'; import sinceRouter from './since.js'; // Support GET requests like v1/history/{object id} to discover all previous versions tracing back to the prime. import historyRouter from './history.js'; +import rest from '../rest.js' router.use(staticRouter) router.use('/id',idRouter) router.use('/api', compatabilityRouter) +// Validate Content-Type headers on all /api write endpoints (fixes #245, #246, #248) +router.use('/api', rest.validateContentType) router.use('/api/query', queryRouter) router.use('/api/search', searchRouter) router.use('/api/create', createRouter) From 5b91d8d7cac02054b2e59ae5baa821529cb3eacf Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 14:49:22 -0500 Subject: [PATCH 02/43] Changes while reviewing --- rest.js | 2 +- routes/__tests__/contentType.test.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/rest.js b/rest.js index 40c7a675..64162ee1 100644 --- a/rest.js +++ b/rest.js @@ -32,7 +32,7 @@ const checkPatchOverrideSupport = function (req, res) { * - Skips validation for methods that don't carry bodies (GET, HEAD, OPTIONS, DELETE) * - Allows text/plain for /search endpoints (which accept plain text search terms) * - Requires application/json or application/ld+json for all other write endpoints - * - Returns 400 for missing Content-Type, 415 for unsupported Content-Type + * - Returns 415 for missing or unsupported Content-Type * * @param {Object} req - Express request object * @param {Object} res - Express response object diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index b846cf6e..1f3850bd 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -1,4 +1,3 @@ -import { jest } from "@jest/globals" import express from "express" import request from "supertest" import rest from '../../rest.js' From 2e014d73cdb33fe5e0c0a42e1f651c8cacaa93fe Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 15:42:00 -0500 Subject: [PATCH 03/43] Changes while reviewing --- rest.js | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/rest.js b/rest.js index 64162ee1..9f639f9a 100644 --- a/rest.js +++ b/rest.js @@ -25,7 +25,7 @@ const checkPatchOverrideSupport = function (req, res) { } /** - * Middleware to validate Content-Type headers on API write endpoints. + * Middleware to validate Content-Type headers. * Ensures that requests carrying bodies have an appropriate Content-Type before * reaching controllers, preventing unhandled errors from unparsed or mis-parsed bodies. * @@ -43,27 +43,15 @@ const validateContentType = function (req, res, next) { if (skipMethods.includes(req.method)) { return next() } - const contentType = req.get("Content-Type") ?? "" - if (!contentType) { res.statusMessage = `Missing Content-Type header. Requests to this endpoint require a Content-Type of "application/json" or "application/ld+json".` res.status(415) return next(res) } - - const isJson = contentType.includes("application/json") || contentType.includes("application/ld+json") - const isText = contentType.includes("text/plain") + if (contentType.includes("application/json") || contentType.includes("application/ld+json")) return next() const isSearchEndpoint = req.path.startsWith("/search") - - if (isJson) { - return next() - } - - if (isText && isSearchEndpoint) { - return next() - } - + if (contentType.includes("text/plain") && isSearchEndpoint) return next() const acceptedTypes = `"application/json" or "application/ld+json"${isSearchEndpoint ? ' or "text/plain"' : ''}` res.statusMessage = `Unsupported Content-Type: "${contentType}". This endpoint requires ${acceptedTypes}.` res.status(415) From 1361b2e331640936379ff3ffb81a14d19f61823c Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 15:52:44 -0500 Subject: [PATCH 04/43] Changes while reviewing --- routes/__tests__/contentType.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index 1f3850bd..7683c347 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -3,7 +3,7 @@ import request from "supertest" import rest from '../../rest.js' // Set up a minimal Express app with the Content-Type validation middleware -const routeTester = new express() +const routeTester = express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) routeTester.use(express.text()) routeTester.use(express.urlencoded({ extended: false })) From 119048120eead2a1df82de2e9025488abac0f642 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 15:57:37 -0500 Subject: [PATCH 05/43] Changes while reviewing --- rest.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest.js b/rest.js index 9f639f9a..905ab5cd 100644 --- a/rest.js +++ b/rest.js @@ -45,7 +45,7 @@ const validateContentType = function (req, res, next) { } const contentType = req.get("Content-Type") ?? "" if (!contentType) { - res.statusMessage = `Missing Content-Type header. Requests to this endpoint require a Content-Type of "application/json" or "application/ld+json".` + res.statusMessage = `Missing Content-Type header.` res.status(415) return next(res) } From c02fe7f468d56645ce69144700b23617772dfffb Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 16:06:58 -0500 Subject: [PATCH 06/43] Changes while reviewing --- rest.js | 2 +- routes/__tests__/contentType.test.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/rest.js b/rest.js index 905ab5cd..c8d88525 100644 --- a/rest.js +++ b/rest.js @@ -43,7 +43,7 @@ const validateContentType = function (req, res, next) { if (skipMethods.includes(req.method)) { return next() } - const contentType = req.get("Content-Type") ?? "" + const contentType = (req.get("Content-Type") ?? "").toLowerCase() if (!contentType) { res.statusMessage = `Missing Content-Type header.` res.status(415) diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index 7683c347..5e07248a 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -102,6 +102,15 @@ describe("Content-Type validation middleware", () => { expect(response.body.received.searchText).toBe("hello") }) + it("accepts Content-Type with unusual casing", async () => { + const response = await request(routeTester) + .post("/api/create") + .set("Content-Type", "Application/JSON") + .send({ test: "casing" }) + expect(response.statusCode).toBe(200) + expect(response.body.received.test).toBe("casing") + }) + it("skips validation for GET requests", async () => { const response = await request(routeTester) .get("/api/info") From deeddc3bf625e4448aff52d14c1d8661773e73e4 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 16:21:07 -0500 Subject: [PATCH 07/43] Changes while reviewing --- rest.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/rest.js b/rest.js index c8d88525..5f621cf0 100644 --- a/rest.js +++ b/rest.js @@ -38,9 +38,10 @@ const checkPatchOverrideSupport = function (req, res) { * @param {Object} res - Express response object * @param {Function} next - Express next middleware function */ +const SKIP_CONTENT_TYPE_METHODS = ["GET", "HEAD", "OPTIONS", "DELETE"] + const validateContentType = function (req, res, next) { - const skipMethods = ["GET", "HEAD", "OPTIONS", "DELETE"] - if (skipMethods.includes(req.method)) { + if (SKIP_CONTENT_TYPE_METHODS.includes(req.method)) { return next() } const contentType = (req.get("Content-Type") ?? "").toLowerCase() @@ -49,9 +50,10 @@ const validateContentType = function (req, res, next) { res.status(415) return next(res) } - if (contentType.includes("application/json") || contentType.includes("application/ld+json")) return next() + const mimeType = contentType.split(";")[0].trim() + if (mimeType === "application/json" || mimeType === "application/ld+json") return next() const isSearchEndpoint = req.path.startsWith("/search") - if (contentType.includes("text/plain") && isSearchEndpoint) return next() + if (mimeType === "text/plain" && isSearchEndpoint) return next() const acceptedTypes = `"application/json" or "application/ld+json"${isSearchEndpoint ? ' or "text/plain"' : ''}` res.statusMessage = `Unsupported Content-Type: "${contentType}". This endpoint requires ${acceptedTypes}.` res.status(415) From eb08b27907ae14c51c1a7d849d6457f77ebb2f5a Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 16:24:00 -0500 Subject: [PATCH 08/43] Changes while reviewing --- rest.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rest.js b/rest.js index 5f621cf0..e3fb217b 100644 --- a/rest.js +++ b/rest.js @@ -38,9 +38,8 @@ const checkPatchOverrideSupport = function (req, res) { * @param {Object} res - Express response object * @param {Function} next - Express next middleware function */ -const SKIP_CONTENT_TYPE_METHODS = ["GET", "HEAD", "OPTIONS", "DELETE"] - const validateContentType = function (req, res, next) { + const SKIP_CONTENT_TYPE_METHODS = ["GET", "HEAD", "OPTIONS", "DELETE"] if (SKIP_CONTENT_TYPE_METHODS.includes(req.method)) { return next() } From fe482d001c176766a18178c73f33a5679e7cf464 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 16:34:13 -0500 Subject: [PATCH 09/43] Changes while reviewing --- rest.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/rest.js b/rest.js index e3fb217b..a4eef6a1 100644 --- a/rest.js +++ b/rest.js @@ -1,11 +1,13 @@ #!/usr/bin/env node +import { createExpressError } from './controllers/utils.js' + /** * This module is used for any REST support functionality. It is used as middleware and so * has access to the http module request and response objects, as well as next() - * It is in charge of responding to the client. - * - * @author thehabes + * It is in charge of responding to the client. + * + * @author thehabes */ /** @@ -45,18 +47,20 @@ const validateContentType = function (req, res, next) { } const contentType = (req.get("Content-Type") ?? "").toLowerCase() if (!contentType) { - res.statusMessage = `Missing Content-Type header.` - res.status(415) - return next(res) + return next(createExpressError({ + statusCode: 415, + statusMessage: `Missing Content-Type header.` + })) } const mimeType = contentType.split(";")[0].trim() if (mimeType === "application/json" || mimeType === "application/ld+json") return next() const isSearchEndpoint = req.path.startsWith("/search") if (mimeType === "text/plain" && isSearchEndpoint) return next() const acceptedTypes = `"application/json" or "application/ld+json"${isSearchEndpoint ? ' or "text/plain"' : ''}` - res.statusMessage = `Unsupported Content-Type: "${contentType}". This endpoint requires ${acceptedTypes}.` - res.status(415) - next(res) + next(createExpressError({ + statusCode: 415, + statusMessage: `Unsupported Content-Type: "${contentType}". This endpoint requires ${acceptedTypes}.` + })) } /** From 1a33fe1a4a3f537e7b132611b6103ccf0aee97db Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 16:54:01 -0500 Subject: [PATCH 10/43] Changes while reviewing --- rest.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rest.js b/rest.js index a4eef6a1..92d4c61f 100644 --- a/rest.js +++ b/rest.js @@ -40,9 +40,9 @@ const checkPatchOverrideSupport = function (req, res) { * @param {Object} res - Express response object * @param {Function} next - Express next middleware function */ +const SKIP_CONTENT_TYPE_METHODS = ["GET", "HEAD", "OPTIONS", "DELETE"] const validateContentType = function (req, res, next) { - const SKIP_CONTENT_TYPE_METHODS = ["GET", "HEAD", "OPTIONS", "DELETE"] - if (SKIP_CONTENT_TYPE_METHODS.includes(req.method)) { + if (SKIP_CONTENT_TYPE_METHODS.includes(req.method) || req.path.startsWith("/release")) { return next() } const contentType = (req.get("Content-Type") ?? "").toLowerCase() @@ -54,7 +54,7 @@ const validateContentType = function (req, res, next) { } const mimeType = contentType.split(";")[0].trim() if (mimeType === "application/json" || mimeType === "application/ld+json") return next() - const isSearchEndpoint = req.path.startsWith("/search") + const isSearchEndpoint = req.path === "/search" || req.path.startsWith("/search/") if (mimeType === "text/plain" && isSearchEndpoint) return next() const acceptedTypes = `"application/json" or "application/ld+json"${isSearchEndpoint ? ' or "text/plain"' : ''}` next(createExpressError({ From 0f5fa250ba3109059e6f3a04a50d4098ab78e469 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 17:06:08 -0500 Subject: [PATCH 11/43] Changes while reviewing --- rest.js | 9 +++++---- routes/__tests__/contentType.test.js | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/rest.js b/rest.js index 92d4c61f..b399029e 100644 --- a/rest.js +++ b/rest.js @@ -42,17 +42,18 @@ const checkPatchOverrideSupport = function (req, res) { */ const SKIP_CONTENT_TYPE_METHODS = ["GET", "HEAD", "OPTIONS", "DELETE"] const validateContentType = function (req, res, next) { - if (SKIP_CONTENT_TYPE_METHODS.includes(req.method) || req.path.startsWith("/release")) { + const isReleaseEndpoint = req.path === "/release" || req.path.startsWith("/release/") + if (SKIP_CONTENT_TYPE_METHODS.includes(req.method) || isReleaseEndpoint) { return next() } const contentType = (req.get("Content-Type") ?? "").toLowerCase() - if (!contentType) { + const mimeType = contentType.split(";")[0].trim() + if (!mimeType) { return next(createExpressError({ statusCode: 415, - statusMessage: `Missing Content-Type header.` + statusMessage: `Missing or empty Content-Type header.` })) } - const mimeType = contentType.split(";")[0].trim() if (mimeType === "application/json" || mimeType === "application/ld+json") return next() const isSearchEndpoint = req.path === "/search" || req.path.startsWith("/search/") if (mimeType === "text/plain" && isSearchEndpoint) return next() diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index 5e07248a..25a03d2c 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -63,7 +63,7 @@ describe("Content-Type validation middleware", () => { .unset("Content-Type") .send(Buffer.from('{"test":"data"}')) expect(response.statusCode).toBe(415) - expect(response.text).toContain("Missing Content-Type header") + expect(response.text).toContain("Missing or empty Content-Type header") }) it("returns 415 for text/plain on JSON-only endpoint", async () => { From d88723a6a2c79023431976af6c9f200dea8e32ec Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 17:07:40 -0500 Subject: [PATCH 12/43] Changes while reviewing --- rest.js | 1 + 1 file changed, 1 insertion(+) diff --git a/rest.js b/rest.js index b399029e..14082652 100644 --- a/rest.js +++ b/rest.js @@ -32,6 +32,7 @@ const checkPatchOverrideSupport = function (req, res) { * reaching controllers, preventing unhandled errors from unparsed or mis-parsed bodies. * * - Skips validation for methods that don't carry bodies (GET, HEAD, OPTIONS, DELETE) + * - Skips validation for endpoints that do not use a body (/release) * - Allows text/plain for /search endpoints (which accept plain text search terms) * - Requires application/json or application/ld+json for all other write endpoints * - Returns 415 for missing or unsupported Content-Type From bdbfdd54c2e8950dff215fffa6f02a5f17f5325a Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 17:21:10 -0500 Subject: [PATCH 13/43] Changes while reviewing --- rest.js | 2 +- routes/__tests__/contentType.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/rest.js b/rest.js index 14082652..67d74dc2 100644 --- a/rest.js +++ b/rest.js @@ -32,7 +32,7 @@ const checkPatchOverrideSupport = function (req, res) { * reaching controllers, preventing unhandled errors from unparsed or mis-parsed bodies. * * - Skips validation for methods that don't carry bodies (GET, HEAD, OPTIONS, DELETE) - * - Skips validation for endpoints that do not use a body (/release) + * - Skips validation for endpoints that don't carry bodies (/release) * - Allows text/plain for /search endpoints (which accept plain text search terms) * - Requires application/json or application/ld+json for all other write endpoints * - Returns 415 for missing or unsupported Content-Type diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index 25a03d2c..1d94f721 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -26,6 +26,16 @@ routeTester.get("/api/info", (req, res) => { res.status(200).json({ info: true }) }) +// DELETE endpoint should pass through without Content-Type validation +routeTester.delete("/api/delete/:_id", (req, res) => { + res.status(200).json({ deleted: req.params._id }) +}) + +// Release endpoint uses PATCH without a JSON body (uses Slug header) +routeTester.patch("/api/release/:_id", (req, res) => { + res.status(200).json({ released: req.params._id }) +}) + // Error handler matching the app's pattern routeTester.use(rest.messenger) @@ -118,4 +128,18 @@ describe("Content-Type validation middleware", () => { expect(response.body.info).toBe(true) }) + it("skips validation for DELETE requests", async () => { + const response = await request(routeTester) + .delete("/api/delete/abc123") + expect(response.statusCode).toBe(200) + expect(response.body.deleted).toBe("abc123") + }) + + it("skips validation for PATCH on release endpoint", async () => { + const response = await request(routeTester) + .patch("/api/release/abc123") + expect(response.statusCode).toBe(200) + expect(response.body.released).toBe("abc123") + }) + }) From 7dcc4ffff8fb217c4d5bbee7cbb758ed5dbb34a4 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 17:35:25 -0500 Subject: [PATCH 14/43] Changes while reviewing and testing --- routes/__tests__/history.test.js | 1 + routes/__tests__/since.test.js | 1 + 2 files changed, 2 insertions(+) diff --git a/routes/__tests__/history.test.js b/routes/__tests__/history.test.js index c4c87b22..818f8291 100644 --- a/routes/__tests__/history.test.js +++ b/routes/__tests__/history.test.js @@ -1,4 +1,5 @@ import { jest } from "@jest/globals" +jest.setTimeout(10000) // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" diff --git a/routes/__tests__/since.test.js b/routes/__tests__/since.test.js index 13f9579f..ca9b714b 100644 --- a/routes/__tests__/since.test.js +++ b/routes/__tests__/since.test.js @@ -1,4 +1,5 @@ import { jest } from "@jest/globals" +jest.setTimeout(10000) // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" From 525b47bcd096b11dae9fc3a0fc9cebff3eb31468 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Mon, 23 Mar 2026 17:49:24 -0500 Subject: [PATCH 15/43] Changes while reviewing and testing --- routes/api-routes.js | 1 - 1 file changed, 1 deletion(-) diff --git a/routes/api-routes.js b/routes/api-routes.js index c92730a6..805155e8 100644 --- a/routes/api-routes.js +++ b/routes/api-routes.js @@ -49,7 +49,6 @@ import rest from '../rest.js' router.use(staticRouter) router.use('/id',idRouter) router.use('/api', compatabilityRouter) -// Validate Content-Type headers on all /api write endpoints (fixes #245, #246, #248) router.use('/api', rest.validateContentType) router.use('/api/query', queryRouter) router.use('/api/search', searchRouter) From 1c9046460c3a627b330a1b095a4ade3846ffaf36 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 09:12:23 -0500 Subject: [PATCH 16/43] Fix createExpressError --- controllers/utils.js | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/controllers/utils.js b/controllers/utils.js index 9da47cea..5d1c62b1 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -100,21 +100,14 @@ const index = function (req, res, next) { } function createExpressError(err) { - let error = {} - if (err.code) { - switch (err.code) { - case 11000: - //Duplicate _id key error, specific to SLUG support. This is a Conflict. - error.statusMessage = `The id provided already exists. Please use a different _id or Slug.` - error.statusCode = 409 - break - default: - error.statusMessage = "There was a mongo error that prevented this request from completing successfully." - error.statusCode = 500 - } + let error = { + statusCode: err.statusCode ?? err.code ?? 500, + statusMessage: err.statusMessage ?? err.message ?? "There was an error that prevented this request from completing successfully." + } + if (err.code === 11000) { + error.statusMessage = `The id provided already exists. Please use a different _id or Slug.` + error.statusCode = 409 } - error.statusCode = err.statusCode ?? err.status ?? 500 - error.statusMessage = err.statusMessage ?? err.message ?? "Detected Error" return error } From 2a7a67d3e689e61e0c0802b19b8f2063c047d0d1 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 09:20:15 -0500 Subject: [PATCH 17/43] Fix createExpressError --- controllers/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/utils.js b/controllers/utils.js index 5d1c62b1..937ccfca 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -101,7 +101,7 @@ const index = function (req, res, next) { function createExpressError(err) { let error = { - statusCode: err.statusCode ?? err.code ?? 500, + statusCode: err.statusCode ?? err.status ?? 500, statusMessage: err.statusMessage ?? err.message ?? "There was an error that prevented this request from completing successfully." } if (err.code === 11000) { From cbb475be76f7f1581f350be202e4637441534d41 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 09:27:47 -0500 Subject: [PATCH 18/43] Changes while testing and reviewing --- rest.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest.js b/rest.js index 67d74dc2..a54ad0e5 100644 --- a/rest.js +++ b/rest.js @@ -121,7 +121,7 @@ Token: ${token}` } else { //If there was no Token, this would be a 401. If you made it here, you didn't REST. - err.message += ` + error.message += ` You are Forbidden from performing this action. The request does not contain an "Authorization" header. Make sure you have registered at ${process.env.RERUM_PREFIX}. ` } From ff149f9c93868f4e06bed90e5103a39da60effa0 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 09:41:14 -0500 Subject: [PATCH 19/43] Changes while testing and reviewing --- routes/__tests__/contentType.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index 1d94f721..1cd84eaa 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -67,6 +67,14 @@ describe("Content-Type validation middleware", () => { expect(response.statusCode).toBe(200) }) + it("accepts application/ld+json with charset parameter", async () => { + const response = await request(routeTester) + .post("/api/create") + .set("Content-Type", "application/ld+json; charset=utf-8") + .send(JSON.stringify({ "@context": "http://example.org", test: "ld-charset" })) + expect(response.statusCode).toBe(200) + }) + it("returns 415 for missing Content-Type header", async () => { const response = await request(routeTester) .post("/api/create") From 53376220b5e2da78cdfe0fd91f2c054308bdd6c5 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 10:22:39 -0500 Subject: [PATCH 20/43] Changes while reviewing and testing --- routes/__tests__/contentType.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index 1cd84eaa..9fb360ea 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -6,7 +6,7 @@ import rest from '../../rest.js' const routeTester = express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) routeTester.use(express.text()) -routeTester.use(express.urlencoded({ extended: false })) +routeTester.use(express.urlencoded({ extended: true })) // Mount the validateContentType middleware on /api just like api-routes.js routeTester.use("/api", rest.validateContentType) From bfa3a285131a9362432ace4c56aa660cb0545ff5 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 10:24:43 -0500 Subject: [PATCH 21/43] Changes while reviewing and testing --- rest.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest.js b/rest.js index a54ad0e5..3a6bde52 100644 --- a/rest.js +++ b/rest.js @@ -58,10 +58,10 @@ const validateContentType = function (req, res, next) { if (mimeType === "application/json" || mimeType === "application/ld+json") return next() const isSearchEndpoint = req.path === "/search" || req.path.startsWith("/search/") if (mimeType === "text/plain" && isSearchEndpoint) return next() - const acceptedTypes = `"application/json" or "application/ld+json"${isSearchEndpoint ? ' or "text/plain"' : ''}` + const acceptedTypes = `application/json or application/ld+json${isSearchEndpoint ? ' or text/plain' : ''}` next(createExpressError({ statusCode: 415, - statusMessage: `Unsupported Content-Type: "${contentType}". This endpoint requires ${acceptedTypes}.` + statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires ${acceptedTypes}.` })) } From f077138c87b0f25bde6659bdc349484d1b319dc3 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 10:43:41 -0500 Subject: [PATCH 22/43] Changes while reviewing and testing --- app.js | 1 - routes/__tests__/bulkCreate.test.js | 1 - routes/__tests__/bulkUpdate.test.js | 1 - routes/__tests__/contentType.test.js | 2 +- routes/__tests__/create.test.js | 1 - routes/__tests__/delete.test.js | 1 - routes/__tests__/history.test.js | 1 - routes/__tests__/id.test.js | 1 - routes/__tests__/overwrite-optimistic-locking.test.txt | 1 - routes/__tests__/patch.test.js | 1 - routes/__tests__/query.test.js | 1 - routes/__tests__/release.test.js | 1 - routes/__tests__/set.test.js | 1 - routes/__tests__/since.test.js | 1 - routes/__tests__/unset.test.js | 1 - routes/__tests__/update.test.js | 1 - routes/static.js | 1 - 17 files changed, 1 insertion(+), 17 deletions(-) diff --git a/app.js b/app.js index 9f416f3c..89953b47 100644 --- a/app.js +++ b/app.js @@ -59,7 +59,6 @@ app.use( app.use(logger('dev')) app.use(express.json({ type: ["application/json", "application/ld+json"] })) app.use(express.text()) -app.use(express.urlencoded({ extended: true })) app.use(cookieParser()) //Publicly available scripts, CSS, and HTML pages. diff --git a/routes/__tests__/bulkCreate.test.js b/routes/__tests__/bulkCreate.test.js index 917cc7e6..129ee9ac 100644 --- a/routes/__tests__/bulkCreate.test.js +++ b/routes/__tests__/bulkCreate.test.js @@ -13,7 +13,6 @@ const addAuth = (req, res, next) => { const routeTester = new express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // Mount our own /bulkCreate route without auth that will use controller.bulkCreate routeTester.use("/bulkCreate", [addAuth, controller.bulkCreate]) diff --git a/routes/__tests__/bulkUpdate.test.js b/routes/__tests__/bulkUpdate.test.js index e857e5a0..f7efe859 100644 --- a/routes/__tests__/bulkUpdate.test.js +++ b/routes/__tests__/bulkUpdate.test.js @@ -13,7 +13,6 @@ const addAuth = (req, res, next) => { const routeTester = new express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // Mount our own /bulkCreate route without auth that will use controller.bulkCreate routeTester.use("/bulkUpdate", [addAuth, controller.bulkUpdate]) diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index 9fb360ea..064933f1 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -6,7 +6,7 @@ import rest from '../../rest.js' const routeTester = express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) routeTester.use(express.text()) -routeTester.use(express.urlencoded({ extended: true })) + // Mount the validateContentType middleware on /api just like api-routes.js routeTester.use("/api", rest.validateContentType) diff --git a/routes/__tests__/create.test.js b/routes/__tests__/create.test.js index 788247f9..17197599 100644 --- a/routes/__tests__/create.test.js +++ b/routes/__tests__/create.test.js @@ -14,7 +14,6 @@ const addAuth = (req, res, next) => { const routeTester = new express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // Mount our own /create route without auth that will use controller.create routeTester.use("/create", [addAuth, controller.create]) diff --git a/routes/__tests__/delete.test.js b/routes/__tests__/delete.test.js index ac012840..659d2deb 100644 --- a/routes/__tests__/delete.test.js +++ b/routes/__tests__/delete.test.js @@ -13,7 +13,6 @@ const addAuth = (req, res, next) => { const routeTester = new express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // FIXME here we need to create something to delete in order to test this route. routeTester.use("/create", [addAuth, controller.create]) diff --git a/routes/__tests__/history.test.js b/routes/__tests__/history.test.js index 818f8291..5ea1117e 100644 --- a/routes/__tests__/history.test.js +++ b/routes/__tests__/history.test.js @@ -8,7 +8,6 @@ import controller from '../../db-controller.js' const routeTester = new express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // Mount our own /history route without auth that will use controller.history routeTester.use("/history/:_id", controller.history) diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index 7300f21b..e081cd7c 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -7,7 +7,6 @@ import controller from '../../db-controller.js' const routeTester = new express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // Mount our own /id route without auth that will use controller.id routeTester.use("/id/:_id", controller.id) diff --git a/routes/__tests__/overwrite-optimistic-locking.test.txt b/routes/__tests__/overwrite-optimistic-locking.test.txt index 3ef6486e..771667f7 100644 --- a/routes/__tests__/overwrite-optimistic-locking.test.txt +++ b/routes/__tests__/overwrite-optimistic-locking.test.txt @@ -26,7 +26,6 @@ const addAuth = (req, res, next) => { // Create a test Express app const routeTester = express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // Mount our routes routeTester.use('/overwrite', [addAuth, controller.overwrite]) diff --git a/routes/__tests__/patch.test.js b/routes/__tests__/patch.test.js index a4d9ebc1..1c63725a 100644 --- a/routes/__tests__/patch.test.js +++ b/routes/__tests__/patch.test.js @@ -14,7 +14,6 @@ const addAuth = (req, res, next) => { const routeTester = new express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // Mount our own /patch route without auth that will use controller.patch routeTester.use("/patch", [addAuth, controller.patchUpdate]) diff --git a/routes/__tests__/query.test.js b/routes/__tests__/query.test.js index b593b6f9..6a4c4751 100644 --- a/routes/__tests__/query.test.js +++ b/routes/__tests__/query.test.js @@ -7,7 +7,6 @@ import controller from '../../db-controller.js' const routeTester = new express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // Mount our own /query route without auth that will use controller.query routeTester.use("/query", controller.query) diff --git a/routes/__tests__/release.test.js b/routes/__tests__/release.test.js index eb6c6e3a..49c028f2 100644 --- a/routes/__tests__/release.test.js +++ b/routes/__tests__/release.test.js @@ -13,7 +13,6 @@ const addAuth = (req, res, next) => { const routeTester = new express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // FIXME here we need to create something to release in order to test this route. routeTester.use("/create", [addAuth, controller.create]) diff --git a/routes/__tests__/set.test.js b/routes/__tests__/set.test.js index 1559356c..dd757cc7 100644 --- a/routes/__tests__/set.test.js +++ b/routes/__tests__/set.test.js @@ -15,7 +15,6 @@ const addAuth = (req, res, next) => { const routeTester = new express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // Mount our own /create route without auth that will use controller.create routeTester.use("/set", [addAuth, controller.patchSet]) diff --git a/routes/__tests__/since.test.js b/routes/__tests__/since.test.js index ca9b714b..3a61c2e0 100644 --- a/routes/__tests__/since.test.js +++ b/routes/__tests__/since.test.js @@ -8,7 +8,6 @@ import controller from '../../db-controller.js' const routeTester = new express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // Mount our own /create route without auth that will use controller.history routeTester.use("/since/:_id", controller.since) diff --git a/routes/__tests__/unset.test.js b/routes/__tests__/unset.test.js index e3c8c97c..96e136a8 100644 --- a/routes/__tests__/unset.test.js +++ b/routes/__tests__/unset.test.js @@ -15,7 +15,6 @@ const addAuth = (req, res, next) => { const routeTester = new express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // Mount our own /create route without auth that will use controller.create routeTester.use("/unset", [addAuth, controller.patchUnset]) diff --git a/routes/__tests__/update.test.js b/routes/__tests__/update.test.js index df5e21a3..ae0b36ed 100644 --- a/routes/__tests__/update.test.js +++ b/routes/__tests__/update.test.js @@ -14,7 +14,6 @@ const addAuth = (req, res, next) => { const routeTester = new express() routeTester.use(express.json()) -routeTester.use(express.urlencoded({ extended: false })) // Mount our own /create route without auth that will use controller.create routeTester.use("/update", [addAuth, controller.putUpdate]) diff --git a/routes/static.js b/routes/static.js index 7189dc57..853edbe6 100644 --- a/routes/static.js +++ b/routes/static.js @@ -14,7 +14,6 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) // public also available at `/v1` -router.use(express.urlencoded({ extended: false })) router.use(express.static(path.join(__dirname, '../public'))) // Set default API response From 232307dd1b7a9ef89056fe114cea0a218ddb1440 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 10:49:12 -0500 Subject: [PATCH 23/43] Changes while reviewing and testing --- rest.js | 2 +- routes/__tests__/contentType.test.js | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/rest.js b/rest.js index 3a6bde52..dc28dc15 100644 --- a/rest.js +++ b/rest.js @@ -59,7 +59,7 @@ const validateContentType = function (req, res, next) { const isSearchEndpoint = req.path === "/search" || req.path.startsWith("/search/") if (mimeType === "text/plain" && isSearchEndpoint) return next() const acceptedTypes = `application/json or application/ld+json${isSearchEndpoint ? ' or text/plain' : ''}` - next(createExpressError({ + return next(createExpressError({ statusCode: 415, statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires ${acceptedTypes}.` })) diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index 064933f1..9f2a8d8f 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -31,6 +31,11 @@ routeTester.delete("/api/delete/:_id", (req, res) => { res.status(200).json({ deleted: req.params._id }) }) +// PUT endpoint (like /api/update, /api/bulkUpdate) +routeTester.put("/api/update", (req, res) => { + res.status(200).json({ received: req.body }) +}) + // Release endpoint uses PATCH without a JSON body (uses Slug header) routeTester.patch("/api/release/:_id", (req, res) => { res.status(200).json({ released: req.params._id }) @@ -150,4 +155,22 @@ describe("Content-Type validation middleware", () => { expect(response.body.released).toBe("abc123") }) + it("accepts application/json on PUT endpoint", async () => { + const response = await request(routeTester) + .put("/api/update") + .set("Content-Type", "application/json") + .send({ test: "put-data" }) + expect(response.statusCode).toBe(200) + expect(response.body.received.test).toBe("put-data") + }) + + it("returns 415 for text/plain on PUT endpoint", async () => { + const response = await request(routeTester) + .put("/api/update") + .set("Content-Type", "text/plain") + .send("some text") + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Unsupported Content-Type") + }) + }) From 2b7ea90813d48c701ca4123c8069761c5594d38e Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 11:06:18 -0500 Subject: [PATCH 24/43] reject content-type headers that have multiple or duplicate types --- rest.js | 6 ++++++ routes/__tests__/contentType.test.js | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/rest.js b/rest.js index dc28dc15..3c7c7023 100644 --- a/rest.js +++ b/rest.js @@ -55,6 +55,12 @@ const validateContentType = function (req, res, next) { statusMessage: `Missing or empty Content-Type header.` })) } + if (contentType.includes(",")) { + return next(createExpressError({ + statusCode: 415, + statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` + })) + } if (mimeType === "application/json" || mimeType === "application/ld+json") return next() const isSearchEndpoint = req.path === "/search" || req.path.startsWith("/search/") if (mimeType === "text/plain" && isSearchEndpoint) return next() diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index 9f2a8d8f..f70b04b9 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -173,4 +173,22 @@ describe("Content-Type validation middleware", () => { expect(response.text).toContain("Unsupported Content-Type") }) + it("returns 415 for comma-separated multiple Content-Type values", async () => { + const response = await request(routeTester) + .post("/api/create") + .set("Content-Type", "application/json, text/plain") + .send('{"test":"data"}') + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") + }) + + it("returns 415 for valid type smuggled via comma after charset", async () => { + const response = await request(routeTester) + .post("/api/create") + .set("Content-Type", "application/json; charset=utf-8, text/plain") + .send('{"test":"data"}') + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") + }) + }) From 6a5dc5cb97144c3bd1992a8fd53c7ba38b7302ea Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 11:10:45 -0500 Subject: [PATCH 25/43] ah ok get those generic mongo error codes --- controllers/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/utils.js b/controllers/utils.js index 937ccfca..1acca194 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -101,7 +101,7 @@ const index = function (req, res, next) { function createExpressError(err) { let error = { - statusCode: err.statusCode ?? err.status ?? 500, + statusCode: err.statusCode ?? err.status ?? err.code ?? 500, statusMessage: err.statusMessage ?? err.message ?? "There was an error that prevented this request from completing successfully." } if (err.code === 11000) { From cbef6a697c6967de598eeb93297014f753da83de Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 11:15:14 -0500 Subject: [PATCH 26/43] ah nebermind --- controllers/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/utils.js b/controllers/utils.js index 1acca194..937ccfca 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -101,7 +101,7 @@ const index = function (req, res, next) { function createExpressError(err) { let error = { - statusCode: err.statusCode ?? err.status ?? err.code ?? 500, + statusCode: err.statusCode ?? err.status ?? 500, statusMessage: err.statusMessage ?? err.message ?? "There was an error that prevented this request from completing successfully." } if (err.code === 11000) { From a06037d62ef47529c8c8018df07e2c5c5abd9546 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 12:24:56 -0500 Subject: [PATCH 27/43] refactor how Content-Type headers are checked --- controllers/utils.js | 1 - rest.js | 80 +++++++++++++++++++++++++++++++++---------- routes/api-routes.js | 4 +-- routes/bulkCreate.js | 3 +- routes/bulkUpdate.js | 3 +- routes/create.js | 3 +- routes/overwrite.js | 3 +- routes/patchSet.js | 3 +- routes/patchUnset.js | 2 +- routes/patchUpdate.js | 2 +- routes/putUpdate.js | 3 +- routes/query.js | 3 +- routes/search.js | 5 +-- 13 files changed, 81 insertions(+), 34 deletions(-) diff --git a/controllers/utils.js b/controllers/utils.js index 937ccfca..7d51baf7 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -5,7 +5,6 @@ * @author Claude Sonnet 4, cubap, thehabes */ import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' const ObjectID = newID diff --git a/rest.js b/rest.js index 3c7c7023..7261a91c 100644 --- a/rest.js +++ b/rest.js @@ -27,26 +27,71 @@ const checkPatchOverrideSupport = function (req, res) { } /** - * Middleware to validate Content-Type headers. - * Ensures that requests carrying bodies have an appropriate Content-Type before - * reaching controllers, preventing unhandled errors from unparsed or mis-parsed bodies. + * Middleware to validate JSON Content-Type headers for endpoints recieving JSON bodies. * - * - Skips validation for methods that don't carry bodies (GET, HEAD, OPTIONS, DELETE) - * - Skips validation for endpoints that don't carry bodies (/release) - * - Allows text/plain for /search endpoints (which accept plain text search terms) - * - Requires application/json or application/ld+json for all other write endpoints - * - Returns 415 for missing or unsupported Content-Type + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {Function} next - Express next middleware function + */ +const jsonContent = function (req, res, next) { + const contentType = (req.get("Content-Type") ?? "").toLowerCase() + const mimeType = contentType.split(";")[0].trim() + if (!mimeType) { + return next(createExpressError({ + statusCode: 415, + statusMessage: `Missing or empty Content-Type header.` + })) + } + if (contentType.includes(",")) { + return next(createExpressError({ + statusCode: 415, + statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` + })) + } + if (mimeType === "application/json" || mimeType === "application/ld+json") return next() + return next(createExpressError({ + statusCode: 415, + statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires application/json or application/ld+json.` + })) +} + +/** + * Middleware to validate Content-Type headers for endpoints recieving textual bodies. * * @param {Object} req - Express request object * @param {Object} res - Express response object * @param {Function} next - Express next middleware function */ -const SKIP_CONTENT_TYPE_METHODS = ["GET", "HEAD", "OPTIONS", "DELETE"] -const validateContentType = function (req, res, next) { - const isReleaseEndpoint = req.path === "/release" || req.path.startsWith("/release/") - if (SKIP_CONTENT_TYPE_METHODS.includes(req.method) || isReleaseEndpoint) { - return next() +const textContent = function (req, res, next) { + const contentType = (req.get("Content-Type") ?? "").toLowerCase() + const mimeType = contentType.split(";")[0].trim() + if (!mimeType) { + return next(createExpressError({ + statusCode: 415, + statusMessage: `Missing or empty Content-Type header.` + })) + } + if (contentType.includes(",")) { + return next(createExpressError({ + statusCode: 415, + statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` + })) } + if (mimeType === "text/plain") return next() + return next(createExpressError({ + statusCode: 415, + statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires text/plain.` + })) +} + +/** + * Middleware to validate Content-Type headers for endpoints recieving either JSON or textual bodies. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {Function} next - Express next middleware function + */ +const eitherContent = function (req, res, next) { const contentType = (req.get("Content-Type") ?? "").toLowerCase() const mimeType = contentType.split(";")[0].trim() if (!mimeType) { @@ -61,13 +106,10 @@ const validateContentType = function (req, res, next) { statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` })) } - if (mimeType === "application/json" || mimeType === "application/ld+json") return next() - const isSearchEndpoint = req.path === "/search" || req.path.startsWith("/search/") - if (mimeType === "text/plain" && isSearchEndpoint) return next() - const acceptedTypes = `application/json or application/ld+json${isSearchEndpoint ? ' or text/plain' : ''}` + if (mimeType === "text/plain" || mimeType === "application/json" || mimeType === "application/ld+json") return next() return next(createExpressError({ statusCode: 415, - statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires ${acceptedTypes}.` + statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires text/plain.` })) } @@ -163,4 +205,4 @@ It may not have completed at all, and most likely did not complete successfully. res.status(error.status).send(error.message) } -export default { checkPatchOverrideSupport, validateContentType, messenger } +export default { checkPatchOverrideSupport, jsonContent, textContent, messenger } diff --git a/routes/api-routes.js b/routes/api-routes.js index 805155e8..abee718e 100644 --- a/routes/api-routes.js +++ b/routes/api-routes.js @@ -44,12 +44,10 @@ import releaseRouter from './release.js'; import sinceRouter from './since.js'; // Support GET requests like v1/history/{object id} to discover all previous versions tracing back to the prime. import historyRouter from './history.js'; -import rest from '../rest.js' router.use(staticRouter) -router.use('/id',idRouter) +router.use('/id', idRouter) router.use('/api', compatabilityRouter) -router.use('/api', rest.validateContentType) router.use('/api/query', queryRouter) router.use('/api/search', searchRouter) router.use('/api/create', createRouter) diff --git a/routes/bulkCreate.js b/routes/bulkCreate.js index 8eb2fc90..6585cf1f 100644 --- a/routes/bulkCreate.js +++ b/routes/bulkCreate.js @@ -5,9 +5,10 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' import auth from '../auth/index.js' +import { jsonContent } from '../rest.js' router.route('/') - .post(auth.checkJwt, controller.bulkCreate) + .post(auth.checkJwt, jsonContent, controller.bulkCreate) .all((req, res, next) => { res.statusMessage = 'Improper request method for creating, please use POST.' res.status(405) diff --git a/routes/bulkUpdate.js b/routes/bulkUpdate.js index f7fad3fa..33b3cbd9 100644 --- a/routes/bulkUpdate.js +++ b/routes/bulkUpdate.js @@ -5,9 +5,10 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' import auth from '../auth/index.js' +import { jsonContent } from '../rest.js' router.route('/') - .put(auth.checkJwt, controller.bulkUpdate) + .put(auth.checkJwt, jsonContent, controller.bulkUpdate) .all((req, res, next) => { res.statusMessage = 'Improper request method for creating, please use PUT.' res.status(405) diff --git a/routes/create.js b/routes/create.js index 97b86975..1f37989b 100644 --- a/routes/create.js +++ b/routes/create.js @@ -4,9 +4,10 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' import auth from '../auth/index.js' +import { jsonContent } from '../rest.js' router.route('/') - .post(auth.checkJwt, controller.create) + .post(auth.checkJwt, jsonContent, controller.create) .all((req, res, next) => { res.statusMessage = 'Improper request method for creating, please use POST.' res.status(405) diff --git a/routes/overwrite.js b/routes/overwrite.js index 08b54fd7..957fc473 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -4,9 +4,10 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' import auth from '../auth/index.js' +import { jsonContent } from '../rest.js' router.route('/') - .put(auth.checkJwt, controller.overwrite) + .put(auth.checkJwt, jsonContent, controller.overwrite) .all((req, res, next) => { res.statusMessage = 'Improper request method for overwriting, please use PUT to overwrite this object.' res.status(405) diff --git a/routes/patchSet.js b/routes/patchSet.js index ff67ec1a..d1b5ae61 100644 --- a/routes/patchSet.js +++ b/routes/patchSet.js @@ -4,9 +4,10 @@ const router = express.Router() import controller from '../db-controller.js' import auth from '../auth/index.js' import rest from '../rest.js' +import { jsonContent } from '../rest.js' router.route('/') - .patch(auth.checkJwt, controller.patchSet) + .patch(auth.checkJwt, jsonContent, controller.patchSet) .post(auth.checkJwt, (req, res, next) => { if (rest.checkPatchOverrideSupport(req, res)) { controller.patchSet(req, res, next) diff --git a/routes/patchUnset.js b/routes/patchUnset.js index 6bdf0b65..b3e56c21 100644 --- a/routes/patchUnset.js +++ b/routes/patchUnset.js @@ -6,7 +6,7 @@ import auth from '../auth/index.js' import rest from '../rest.js' router.route('/') - .patch(auth.checkJwt, controller.patchUnset) + .patch(auth.checkJwt, rest.jsonContent, controller.patchUnset) .post(auth.checkJwt, (req, res, next) => { if (rest.checkPatchOverrideSupport(req, res)) { controller.patchUnset(req, res, next) diff --git a/routes/patchUpdate.js b/routes/patchUpdate.js index 5df088bf..31e26d00 100644 --- a/routes/patchUpdate.js +++ b/routes/patchUpdate.js @@ -7,7 +7,7 @@ import rest from '../rest.js' import auth from '../auth/index.js' router.route('/') - .patch(auth.checkJwt, controller.patchUpdate) + .patch(auth.checkJwt, rest.jsonContent, controller.patchUpdate) .post(auth.checkJwt, (req, res, next) => { if (rest.checkPatchOverrideSupport(req, res)) { controller.patchUpdate(req, res, next) diff --git a/routes/putUpdate.js b/routes/putUpdate.js index d9397122..2d13dba5 100644 --- a/routes/putUpdate.js +++ b/routes/putUpdate.js @@ -4,9 +4,10 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' import auth from '../auth/index.js' +import { jsonContent } from '../rest.js' router.route('/') - .put(auth.checkJwt, controller.putUpdate) + .put(auth.checkJwt, jsonContent, controller.putUpdate) .all((req, res, next) => { res.statusMessage = 'Improper request method for updating, please use PUT to update this object.' res.status(405) diff --git a/routes/query.js b/routes/query.js index 61c33c9b..cc9f9e66 100644 --- a/routes/query.js +++ b/routes/query.js @@ -2,9 +2,10 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' +import { jsonContent } from '../rest.js' router.route('/') - .post(controller.query) + .post(jsonContent, controller.query) .head(controller.queryHeadRequest) .all((req, res, next) => { res.statusMessage = 'Improper request method for requesting objects with matching properties. Please use POST.' diff --git a/routes/search.js b/routes/search.js index 2053bf5a..0cd0fb63 100644 --- a/routes/search.js +++ b/routes/search.js @@ -1,9 +1,10 @@ import express from 'express' const router = express.Router() import controller from '../db-controller.js' +import { eitherContent } from '../rest.js' router.route('/') - .post(controller.searchAsWords) + .post(eitherContent, controller.searchAsWords) .all((req, res, next) => { res.statusMessage = 'Improper request method for search. Please use POST.' res.status(405) @@ -11,7 +12,7 @@ router.route('/') }) router.route('/phrase') - .post(controller.searchAsPhrase) + .post(eitherContent, controller.searchAsPhrase) .all((req, res, next) => { res.statusMessage = 'Improper request method for search. Please use POST.' res.status(405) From 99522c4970347d2ff05baa41f7659d645a35f52a Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 12:33:22 -0500 Subject: [PATCH 28/43] refactor how Content-Type headers are checked --- rest.js | 4 +- routes/__tests__/contentType.test.js | 229 +++++++++++++++++---------- routes/bulkCreate.js | 4 +- routes/bulkUpdate.js | 4 +- routes/create.js | 4 +- routes/overwrite.js | 4 +- routes/patchSet.js | 3 +- routes/putUpdate.js | 4 +- routes/query.js | 4 +- routes/search.js | 6 +- 10 files changed, 167 insertions(+), 99 deletions(-) diff --git a/rest.js b/rest.js index 7261a91c..24a44196 100644 --- a/rest.js +++ b/rest.js @@ -109,7 +109,7 @@ const eitherContent = function (req, res, next) { if (mimeType === "text/plain" || mimeType === "application/json" || mimeType === "application/ld+json") return next() return next(createExpressError({ statusCode: 415, - statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires text/plain.` + statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires application/json, application/ld+json, or text/plain.` })) } @@ -205,4 +205,4 @@ It may not have completed at all, and most likely did not complete successfully. res.status(error.status).send(error.message) } -export default { checkPatchOverrideSupport, jsonContent, textContent, messenger } +export default { checkPatchOverrideSupport, jsonContent, textContent, eitherContent, messenger } diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index f70b04b9..fb9a8df6 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -2,63 +2,56 @@ import express from "express" import request from "supertest" import rest from '../../rest.js' -// Set up a minimal Express app with the Content-Type validation middleware +/** + * Tests for the Content-Type validation middlewares: jsonContent, textContent, and eitherContent. + * Each middleware is applied per-route rather than as a blanket middleware. + */ + +// Set up a minimal Express app mirroring the real app's body parsers const routeTester = express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) routeTester.use(express.text()) - -// Mount the validateContentType middleware on /api just like api-routes.js -routeTester.use("/api", rest.validateContentType) - -// Simple JSON-only endpoint (like /api/create, /api/query, etc.) -routeTester.post("/api/create", (req, res) => { +// JSON-only endpoints (like /api/create, /api/query, /api/update, etc.) +routeTester.post("/json-endpoint", rest.jsonContent, (req, res) => { res.status(200).json({ received: req.body }) }) - -// Search endpoint that accepts text/plain -routeTester.post("/api/search", (req, res) => { +routeTester.put("/json-endpoint", rest.jsonContent, (req, res) => { res.status(200).json({ received: req.body }) }) - -// GET endpoint should pass through without Content-Type validation -routeTester.get("/api/info", (req, res) => { - res.status(200).json({ info: true }) -}) - -// DELETE endpoint should pass through without Content-Type validation -routeTester.delete("/api/delete/:_id", (req, res) => { - res.status(200).json({ deleted: req.params._id }) +routeTester.patch("/json-endpoint", rest.jsonContent, (req, res) => { + res.status(200).json({ received: req.body }) }) -// PUT endpoint (like /api/update, /api/bulkUpdate) -routeTester.put("/api/update", (req, res) => { +// Text-only endpoint +routeTester.post("/text-endpoint", rest.textContent, (req, res) => { res.status(200).json({ received: req.body }) }) -// Release endpoint uses PATCH without a JSON body (uses Slug header) -routeTester.patch("/api/release/:_id", (req, res) => { - res.status(200).json({ released: req.params._id }) +// Either JSON or text endpoint (like /api/search) +routeTester.post("/either-endpoint", rest.eitherContent, (req, res) => { + res.status(200).json({ received: req.body }) }) // Error handler matching the app's pattern routeTester.use(rest.messenger) -describe("Content-Type validation middleware", () => { +describe("jsonContent middleware", () => { - it("accepts application/json with valid JSON body", async () => { + it("accepts application/json", async () => { const response = await request(routeTester) - .post("/api/create") + .post("/json-endpoint") .set("Content-Type", "application/json") .send({ test: "data" }) expect(response.statusCode).toBe(200) expect(response.body.received.test).toBe("data") }) - it("accepts application/ld+json with valid JSON body", async () => { + it("accepts application/ld+json", async () => { const response = await request(routeTester) - .post("/api/create") + .post("/json-endpoint") .set("Content-Type", "application/ld+json") + // Must stringify manually; supertest's .send(object) would override Content-Type to application/json .send(JSON.stringify({ "@context": "http://example.org", test: "ld" })) expect(response.statusCode).toBe(200) expect(response.body.received["@context"]).toBe("http://example.org") @@ -66,7 +59,7 @@ describe("Content-Type validation middleware", () => { it("accepts application/json with charset parameter", async () => { const response = await request(routeTester) - .post("/api/create") + .post("/json-endpoint") .set("Content-Type", "application/json; charset=utf-8") .send({ test: "charset" }) expect(response.statusCode).toBe(200) @@ -74,121 +67,197 @@ describe("Content-Type validation middleware", () => { it("accepts application/ld+json with charset parameter", async () => { const response = await request(routeTester) - .post("/api/create") + .post("/json-endpoint") .set("Content-Type", "application/ld+json; charset=utf-8") + // Must stringify manually; supertest's .send(object) would override Content-Type to application/json .send(JSON.stringify({ "@context": "http://example.org", test: "ld-charset" })) expect(response.statusCode).toBe(200) }) - it("returns 415 for missing Content-Type header", async () => { + it("accepts Content-Type with unusual casing", async () => { + const response = await request(routeTester) + .post("/json-endpoint") + .set("Content-Type", "Application/JSON") + .send({ test: "casing" }) + expect(response.statusCode).toBe(200) + expect(response.body.received.test).toBe("casing") + }) + + it("accepts application/json on PUT", async () => { + const response = await request(routeTester) + .put("/json-endpoint") + .set("Content-Type", "application/json") + .send({ test: "put-data" }) + expect(response.statusCode).toBe(200) + expect(response.body.received.test).toBe("put-data") + }) + + it("accepts application/json on PATCH", async () => { const response = await request(routeTester) - .post("/api/create") + .patch("/json-endpoint") + .set("Content-Type", "application/json") + .send({ test: "patch-data" }) + expect(response.statusCode).toBe(200) + expect(response.body.received.test).toBe("patch-data") + }) + + it("returns 415 for missing Content-Type", async () => { + const response = await request(routeTester) + .post("/json-endpoint") .unset("Content-Type") .send(Buffer.from('{"test":"data"}')) expect(response.statusCode).toBe(415) expect(response.text).toContain("Missing or empty Content-Type header") }) - it("returns 415 for text/plain on JSON-only endpoint", async () => { + it("returns 415 for text/plain", async () => { const response = await request(routeTester) - .post("/api/create") + .post("/json-endpoint") .set("Content-Type", "text/plain") .send("some plain text") expect(response.statusCode).toBe(415) expect(response.text).toContain("Unsupported Content-Type") }) + it("returns 415 for text/plain on PUT", async () => { + const response = await request(routeTester) + .put("/json-endpoint") + .set("Content-Type", "text/plain") + .send("some text") + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Unsupported Content-Type") + }) + it("returns 415 for application/xml", async () => { const response = await request(routeTester) - .post("/api/create") + .post("/json-endpoint") .set("Content-Type", "application/xml") .send("") expect(response.statusCode).toBe(415) expect(response.text).toContain("Unsupported Content-Type") }) - it("allows text/plain on search endpoint", async () => { + it("returns 415 for comma-separated multiple Content-Type values", async () => { const response = await request(routeTester) - .post("/api/search") + .post("/json-endpoint") + .set("Content-Type", "application/json, text/plain") + .send('{"test":"data"}') + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") + }) + + it("returns 415 for valid type smuggled via comma after charset", async () => { + const response = await request(routeTester) + .post("/json-endpoint") + .set("Content-Type", "application/json; charset=utf-8, text/plain") + .send('{"test":"data"}') + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") + }) +}) + +describe("textContent middleware", () => { + + it("accepts text/plain", async () => { + const response = await request(routeTester) + .post("/text-endpoint") .set("Content-Type", "text/plain") - .send("search terms") + .send("hello world") expect(response.statusCode).toBe(200) - expect(response.body.received).toBe("search terms") + expect(response.body.received).toBe("hello world") }) - it("allows application/json on search endpoint", async () => { + it("accepts text/plain with charset parameter", async () => { const response = await request(routeTester) - .post("/api/search") - .set("Content-Type", "application/json") - .send({ searchText: "hello" }) + .post("/text-endpoint") + .set("Content-Type", "text/plain; charset=utf-8") + .send("hello charset") expect(response.statusCode).toBe(200) - expect(response.body.received.searchText).toBe("hello") }) - it("accepts Content-Type with unusual casing", async () => { + it("returns 415 for missing Content-Type", async () => { const response = await request(routeTester) - .post("/api/create") - .set("Content-Type", "Application/JSON") - .send({ test: "casing" }) - expect(response.statusCode).toBe(200) - expect(response.body.received.test).toBe("casing") + .post("/text-endpoint") + .unset("Content-Type") + .send(Buffer.from("hello")) + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Missing or empty Content-Type header") }) - it("skips validation for GET requests", async () => { + it("returns 415 for application/json", async () => { const response = await request(routeTester) - .get("/api/info") - expect(response.statusCode).toBe(200) - expect(response.body.info).toBe(true) + .post("/text-endpoint") + .set("Content-Type", "application/json") + .send({ test: "data" }) + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Unsupported Content-Type") + expect(response.text).toContain("text/plain") }) - it("skips validation for DELETE requests", async () => { + it("returns 415 for comma-separated multiple Content-Type values", async () => { const response = await request(routeTester) - .delete("/api/delete/abc123") - expect(response.statusCode).toBe(200) - expect(response.body.deleted).toBe("abc123") + .post("/text-endpoint") + .set("Content-Type", "text/plain, application/json") + .send("hello") + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") }) +}) + +describe("eitherContent middleware", () => { - it("skips validation for PATCH on release endpoint", async () => { + it("accepts application/json", async () => { const response = await request(routeTester) - .patch("/api/release/abc123") + .post("/either-endpoint") + .set("Content-Type", "application/json") + .send({ searchText: "hello" }) expect(response.statusCode).toBe(200) - expect(response.body.released).toBe("abc123") + expect(response.body.received.searchText).toBe("hello") }) - it("accepts application/json on PUT endpoint", async () => { + it("accepts application/ld+json", async () => { const response = await request(routeTester) - .put("/api/update") - .set("Content-Type", "application/json") - .send({ test: "put-data" }) + .post("/either-endpoint") + .set("Content-Type", "application/ld+json") + // Must stringify manually; supertest's .send(object) would override Content-Type to application/json + .send(JSON.stringify({ "@context": "http://example.org" })) expect(response.statusCode).toBe(200) - expect(response.body.received.test).toBe("put-data") + expect(response.body.received["@context"]).toBe("http://example.org") }) - it("returns 415 for text/plain on PUT endpoint", async () => { + it("accepts text/plain", async () => { const response = await request(routeTester) - .put("/api/update") + .post("/either-endpoint") .set("Content-Type", "text/plain") - .send("some text") + .send("search terms") + expect(response.statusCode).toBe(200) + expect(response.body.received).toBe("search terms") + }) + + it("returns 415 for missing Content-Type", async () => { + const response = await request(routeTester) + .post("/either-endpoint") + .unset("Content-Type") + .send(Buffer.from("hello")) expect(response.statusCode).toBe(415) - expect(response.text).toContain("Unsupported Content-Type") + expect(response.text).toContain("Missing or empty Content-Type header") }) - it("returns 415 for comma-separated multiple Content-Type values", async () => { + it("returns 415 for application/xml", async () => { const response = await request(routeTester) - .post("/api/create") - .set("Content-Type", "application/json, text/plain") - .send('{"test":"data"}') + .post("/either-endpoint") + .set("Content-Type", "application/xml") + .send("") expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") + expect(response.text).toContain("Unsupported Content-Type") }) - it("returns 415 for valid type smuggled via comma after charset", async () => { + it("returns 415 for comma-separated multiple Content-Type values", async () => { const response = await request(routeTester) - .post("/api/create") - .set("Content-Type", "application/json; charset=utf-8, text/plain") + .post("/either-endpoint") + .set("Content-Type", "application/json, text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) expect(response.text).toContain("Multiple Content-Type values are not allowed") }) - }) diff --git a/routes/bulkCreate.js b/routes/bulkCreate.js index 6585cf1f..86147b76 100644 --- a/routes/bulkCreate.js +++ b/routes/bulkCreate.js @@ -5,10 +5,10 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' import auth from '../auth/index.js' -import { jsonContent } from '../rest.js' +import rest from '../rest.js' router.route('/') - .post(auth.checkJwt, jsonContent, controller.bulkCreate) + .post(auth.checkJwt, rest.jsonContent, controller.bulkCreate) .all((req, res, next) => { res.statusMessage = 'Improper request method for creating, please use POST.' res.status(405) diff --git a/routes/bulkUpdate.js b/routes/bulkUpdate.js index 33b3cbd9..6d27988d 100644 --- a/routes/bulkUpdate.js +++ b/routes/bulkUpdate.js @@ -5,10 +5,10 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' import auth from '../auth/index.js' -import { jsonContent } from '../rest.js' +import rest from '../rest.js' router.route('/') - .put(auth.checkJwt, jsonContent, controller.bulkUpdate) + .put(auth.checkJwt, rest.jsonContent, controller.bulkUpdate) .all((req, res, next) => { res.statusMessage = 'Improper request method for creating, please use PUT.' res.status(405) diff --git a/routes/create.js b/routes/create.js index 1f37989b..3b1d7850 100644 --- a/routes/create.js +++ b/routes/create.js @@ -4,10 +4,10 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' import auth from '../auth/index.js' -import { jsonContent } from '../rest.js' +import rest from '../rest.js' router.route('/') - .post(auth.checkJwt, jsonContent, controller.create) + .post(auth.checkJwt, rest.jsonContent, controller.create) .all((req, res, next) => { res.statusMessage = 'Improper request method for creating, please use POST.' res.status(405) diff --git a/routes/overwrite.js b/routes/overwrite.js index 957fc473..42701638 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -4,10 +4,10 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' import auth from '../auth/index.js' -import { jsonContent } from '../rest.js' +import rest from '../rest.js' router.route('/') - .put(auth.checkJwt, jsonContent, controller.overwrite) + .put(auth.checkJwt, rest.jsonContent, controller.overwrite) .all((req, res, next) => { res.statusMessage = 'Improper request method for overwriting, please use PUT to overwrite this object.' res.status(405) diff --git a/routes/patchSet.js b/routes/patchSet.js index d1b5ae61..abf23cfd 100644 --- a/routes/patchSet.js +++ b/routes/patchSet.js @@ -4,10 +4,9 @@ const router = express.Router() import controller from '../db-controller.js' import auth from '../auth/index.js' import rest from '../rest.js' -import { jsonContent } from '../rest.js' router.route('/') - .patch(auth.checkJwt, jsonContent, controller.patchSet) + .patch(auth.checkJwt, rest.jsonContent, controller.patchSet) .post(auth.checkJwt, (req, res, next) => { if (rest.checkPatchOverrideSupport(req, res)) { controller.patchSet(req, res, next) diff --git a/routes/putUpdate.js b/routes/putUpdate.js index 2d13dba5..a87636da 100644 --- a/routes/putUpdate.js +++ b/routes/putUpdate.js @@ -4,10 +4,10 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' import auth from '../auth/index.js' -import { jsonContent } from '../rest.js' +import rest from '../rest.js' router.route('/') - .put(auth.checkJwt, jsonContent, controller.putUpdate) + .put(auth.checkJwt, rest.jsonContent, controller.putUpdate) .all((req, res, next) => { res.statusMessage = 'Improper request method for updating, please use PUT to update this object.' res.status(405) diff --git a/routes/query.js b/routes/query.js index cc9f9e66..181cb19e 100644 --- a/routes/query.js +++ b/routes/query.js @@ -2,10 +2,10 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' -import { jsonContent } from '../rest.js' +import rest from '../rest.js' router.route('/') - .post(jsonContent, controller.query) + .post(rest.jsonContent, controller.query) .head(controller.queryHeadRequest) .all((req, res, next) => { res.statusMessage = 'Improper request method for requesting objects with matching properties. Please use POST.' diff --git a/routes/search.js b/routes/search.js index 0cd0fb63..e90be615 100644 --- a/routes/search.js +++ b/routes/search.js @@ -1,10 +1,10 @@ import express from 'express' const router = express.Router() import controller from '../db-controller.js' -import { eitherContent } from '../rest.js' +import rest from '../rest.js' router.route('/') - .post(eitherContent, controller.searchAsWords) + .post(rest.eitherContent, controller.searchAsWords) .all((req, res, next) => { res.statusMessage = 'Improper request method for search. Please use POST.' res.status(405) @@ -12,7 +12,7 @@ router.route('/') }) router.route('/phrase') - .post(eitherContent, controller.searchAsPhrase) + .post(rest.eitherContent, controller.searchAsPhrase) .all((req, res, next) => { res.statusMessage = 'Improper request method for search. Please use POST.' res.status(405) From e8dff8d698795891a69c97e7c646878d4672cc26 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 13:02:55 -0500 Subject: [PATCH 29/43] refactor how Content-Type headers are checked --- controllers/bulk.js | 4 ++-- controllers/crud.js | 4 ++-- controllers/delete.js | 4 ++-- controllers/gog.js | 4 ++-- controllers/history.js | 4 ++-- controllers/overwrite.js | 4 ++-- controllers/patchSet.js | 4 ++-- controllers/patchUnset.js | 4 ++-- controllers/patchUpdate.js | 4 ++-- controllers/putUpdate.js | 4 ++-- controllers/release.js | 4 ++-- controllers/search.js | 4 ++-- controllers/utils.js | 14 +------------- rest.js | 2 +- utils.js | 22 +++++++++++++++++++++- 15 files changed, 47 insertions(+), 39 deletions(-) diff --git a/controllers/bulk.js b/controllers/bulk.js index 35e7fcb5..fbdc42ef 100644 --- a/controllers/bulk.js +++ b/controllers/bulk.js @@ -7,8 +7,8 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' -import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' +import utils, { createExpressError } from '../utils.js' +import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' /** * Create many objects at once with the power of MongoDB bulkWrite() operations. diff --git a/controllers/crud.js b/controllers/crud.js index 7702de58..9a3ba485 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -5,8 +5,8 @@ * @author Claude Sonnet 4, cubap, thehabes */ import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' -import { _contextid, idNegotiation, generateSlugId, ObjectID, createExpressError, getAgentClaim, parseDocumentID } from './utils.js' +import utils, { createExpressError } from '../utils.js' +import { _contextid, idNegotiation, generateSlugId, ObjectID, getAgentClaim, parseDocumentID } from './utils.js' /** * Create a new Linked Open Data object in RERUM v1. diff --git a/controllers/delete.js b/controllers/delete.js index 12aec2ac..7e2bc644 100644 --- a/controllers/delete.js +++ b/controllers/delete.js @@ -5,8 +5,8 @@ * @author Claude Sonnet 4, cubap, thehabes */ import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' -import { createExpressError, getAgentClaim, parseDocumentID, getAllVersions, getAllDescendants } from './utils.js' +import utils, { createExpressError } from '../utils.js' +import { getAgentClaim, parseDocumentID, getAllVersions, getAllDescendants } from './utils.js' /** * Mark an object as deleted in the database. diff --git a/controllers/gog.js b/controllers/gog.js index 67dd04de..bf84bc84 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -7,8 +7,8 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' -import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' +import utils, { createExpressError } from '../utils.js' +import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' /** * THIS IS SPECIFICALLY FOR 'Gallery of Glosses' diff --git a/controllers/history.js b/controllers/history.js index f0ad0031..c38f089a 100644 --- a/controllers/history.js +++ b/controllers/history.js @@ -7,8 +7,8 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' -import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation, getAllVersions, getAllAncestors, getAllDescendants } from './utils.js' +import utils, { createExpressError } from '../utils.js' +import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, getAllVersions, getAllAncestors, getAllDescendants } from './utils.js' /** * Public facing servlet to gather for all versions downstream from a provided `key object`. diff --git a/controllers/overwrite.js b/controllers/overwrite.js index 284fac89..fbe90608 100644 --- a/controllers/overwrite.js +++ b/controllers/overwrite.js @@ -7,8 +7,8 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' -import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' +import utils, { createExpressError } from '../utils.js' +import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' /** * Replace some existing object in MongoDB with the JSON object in the request body. diff --git a/controllers/patchSet.js b/controllers/patchSet.js index 85e97af8..cb35d230 100644 --- a/controllers/patchSet.js +++ b/controllers/patchSet.js @@ -7,8 +7,8 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' -import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' +import utils, { createExpressError } from '../utils.js' +import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' /** * Update some existing object in MongoDB by adding the keys from the JSON object in the request body. diff --git a/controllers/patchUnset.js b/controllers/patchUnset.js index c4cf53d7..340e16a8 100644 --- a/controllers/patchUnset.js +++ b/controllers/patchUnset.js @@ -7,8 +7,8 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' -import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' +import utils, { createExpressError } from '../utils.js' +import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' /** * Update some existing object in MongoDB by removing the keys noted in the JSON object in the request body. diff --git a/controllers/patchUpdate.js b/controllers/patchUpdate.js index c7271bbb..71ff09f0 100644 --- a/controllers/patchUpdate.js +++ b/controllers/patchUpdate.js @@ -7,8 +7,8 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' -import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' +import utils, { createExpressError } from '../utils.js' +import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' /** * Update some existing object in MongoDB by changing the keys from the JSON object in the request body. diff --git a/controllers/putUpdate.js b/controllers/putUpdate.js index 177507ac..e6f167b7 100644 --- a/controllers/putUpdate.js +++ b/controllers/putUpdate.js @@ -7,8 +7,8 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' -import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' +import utils, { createExpressError } from '../utils.js' +import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' /** * Replace some existing object in MongoDB with the JSON object in the request body. diff --git a/controllers/release.js b/controllers/release.js index 62f26f04..d2c47b35 100644 --- a/controllers/release.js +++ b/controllers/release.js @@ -7,8 +7,8 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' -import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation, generateSlugId, establishReleasesTree, healReleasesTree } from './utils.js' +import utils, { createExpressError } from '../utils.js' +import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, generateSlugId, establishReleasesTree, healReleasesTree } from './utils.js' /** * Public facing servlet to release an existing RERUM object. This will not diff --git a/controllers/search.js b/controllers/search.js index 5a688abf..fd8f8a4e 100644 --- a/controllers/search.js +++ b/controllers/search.js @@ -5,8 +5,8 @@ * @author thehabes */ import { db } from '../database/index.js' -import utils from '../utils.js' -import { idNegotiation, createExpressError } from './utils.js' +import utils, { createExpressError } from '../utils.js' +import { idNegotiation } from './utils.js' /** * Merges and deduplicates results from multiple MongoDB Atlas Search index queries. diff --git a/controllers/utils.js b/controllers/utils.js index 7d51baf7..8e5716ad 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -5,6 +5,7 @@ * @author Claude Sonnet 4, cubap, thehabes */ import { newID, isValidID, db } from '../database/index.js' +import { createExpressError } from '../utils.js' const ObjectID = newID @@ -98,18 +99,6 @@ const index = function (req, res, next) { }) } -function createExpressError(err) { - let error = { - statusCode: err.statusCode ?? err.status ?? 500, - statusMessage: err.statusMessage ?? err.message ?? "There was an error that prevented this request from completing successfully." - } - if (err.code === 11000) { - error.statusMessage = `The id provided already exists. Please use a different _id or Slug.` - error.statusCode = 409 - } - return error -} - /** * An internal helper for removing a document from the database using a known _id or __rerums.slug. * This is not exposed over the http request and response. @@ -461,7 +450,6 @@ export { generateSlugId, index, ObjectID, - createExpressError, remove, getAgentClaim, parseDocumentID, diff --git a/rest.js b/rest.js index 24a44196..a35c8768 100644 --- a/rest.js +++ b/rest.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { createExpressError } from './controllers/utils.js' +import { createExpressError } from './utils.js' /** * This module is used for any REST support functionality. It is used as middleware and so diff --git a/utils.js b/utils.js index be1f9c24..bad9ab33 100644 --- a/utils.js +++ b/utils.js @@ -239,8 +239,28 @@ const configureLastModifiedHeader = function(obj){ return {"Last-Modified":new Date(date).toUTCString()} } +/** + * Create a standardized Express error object from an error or error-like input. + * Handles MongoDB duplicate key errors (code 11000) as 409 Conflict. + * + * @param {Object} err - An error or object with statusCode/status and statusMessage/message properties + * @return {Object} A normalized error object with statusCode and statusMessage + */ +export function createExpressError(err) { + let error = { + statusCode: err.statusCode ?? err.status ?? 500, + statusMessage: err.statusMessage ?? err.message ?? "There was an error that prevented this request from completing successfully." + } + if (err.code === 11000) { + error.statusMessage = `The id provided already exists. Please use a different _id or Slug.` + error.statusCode = 409 + } + return error +} + export default { configureRerumOptions, + createExpressError, isDeleted, isReleased, isGenerator, @@ -249,4 +269,4 @@ export default { isContainerType, isLD, configureLastModifiedHeader -} \ No newline at end of file +} From d194bbc28e18f5eec0bb09994e790717130f296d Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 13:13:21 -0500 Subject: [PATCH 30/43] refactor how createExpressError is used --- controllers/bulk.js | 22 +++++++++++----------- controllers/crud.js | 14 +++++++------- controllers/delete.js | 16 ++++++++-------- controllers/gog.js | 10 +++++----- controllers/history.js | 26 +++++++++++++------------- controllers/overwrite.js | 8 ++++---- controllers/patchSet.js | 8 ++++---- controllers/patchUnset.js | 8 ++++---- controllers/patchUpdate.js | 8 ++++---- controllers/putUpdate.js | 10 +++++----- controllers/release.js | 12 ++++++------ controllers/search.js | 24 ++++++++++++------------ controllers/utils.js | 6 +++--- rest.js | 22 +++++++++++----------- utils.js | 2 +- 15 files changed, 98 insertions(+), 98 deletions(-) diff --git a/controllers/bulk.js b/controllers/bulk.js index fbdc42ef..5e019a27 100644 --- a/controllers/bulk.js +++ b/controllers/bulk.js @@ -7,7 +7,7 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils, { createExpressError } from '../utils.js' +import utils from '../utils.js' import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' /** @@ -22,13 +22,13 @@ const bulkCreate = async function (req, res, next) { if (!Array.isArray(documents)) { err.message = "The request body must be an array of objects." err.status = 400 - next(createExpressError(err)) + next(utils.createExpressError(err)) return } if (documents.length === 0) { err.message = "No action on an empty array." err.status = 400 - next(createExpressError(err)) + next(utils.createExpressError(err)) return } const gatekeep = documents.filter(d=> { @@ -46,7 +46,7 @@ const bulkCreate = async function (req, res, next) { if (gatekeep.length > 0) { err.message = "All objects in the body of a `/bulkCreate` must be JSON and must not contain a declared identifier property." err.status = 400 - next(createExpressError(err)) + next(utils.createExpressError(err)) return } @@ -55,7 +55,7 @@ const bulkCreate = async function (req, res, next) { // if(slug){ // const slugError = await exports.generateSlugId(slug) // if(slugError){ - // next(createExpressError(slugError)) + // next(utils.createExpressError(slugError)) // return // } // else{ @@ -92,7 +92,7 @@ const bulkCreate = async function (req, res, next) { } catch (error) { //MongoServerError from the client has the following properties: index, code, keyPattern, keyValue - next(createExpressError(error)) + next(utils.createExpressError(error)) } } @@ -111,13 +111,13 @@ const bulkUpdate = async function (req, res, next) { if (!Array.isArray(documents)) { err.message = "The request body must be an array of objects." err.status = 400 - next(createExpressError(err)) + next(utils.createExpressError(err)) return } if (documents.length === 0) { err.message = "No action on an empty array." err.status = 400 - next(createExpressError(err)) + next(utils.createExpressError(err)) return } const gatekeep = documents.filter(d => { @@ -136,7 +136,7 @@ const bulkUpdate = async function (req, res, next) { if (gatekeep.length > 0) { err.message = "All objects in the body of a `/bulkUpdate` must be JSON and must contain a declared identifier property." err.status = 400 - next(createExpressError(err)) + next(utils.createExpressError(err)) return } // unordered bulkWrite() operations have better performance metrics. @@ -154,7 +154,7 @@ const bulkUpdate = async function (req, res, next) { try { originalObject = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (null === originalObject) continue @@ -196,7 +196,7 @@ const bulkUpdate = async function (req, res, next) { } catch (error) { //MongoServerError from the client has the following properties: index, code, keyPattern, keyValue - next(createExpressError(error)) + next(utils.createExpressError(error)) } } diff --git a/controllers/crud.js b/controllers/crud.js index 9a3ba485..9aff0f17 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -5,7 +5,7 @@ * @author Claude Sonnet 4, cubap, thehabes */ import { newID, isValidID, db } from '../database/index.js' -import utils, { createExpressError } from '../utils.js' +import utils from '../utils.js' import { _contextid, idNegotiation, generateSlugId, ObjectID, getAgentClaim, parseDocumentID } from './utils.js' /** @@ -19,7 +19,7 @@ const create = async function (req, res, next) { if(req.get("Slug")){ let slug_json = await generateSlugId(req.get("Slug"), next) if(slug_json.code){ - next(createExpressError(slug_json)) + next(utils.createExpressError(slug_json)) return } else{ @@ -55,7 +55,7 @@ const create = async function (req, res, next) { } catch (error) { //MongoServerError from the client has the following properties: index, code, keyPattern, keyValue - next(createExpressError(error)) + next(utils.createExpressError(error)) } } @@ -75,7 +75,7 @@ const query = async function (req, res, next) { message: "Detected empty JSON object. You must provide at least one property in the /query request body JSON.", status: 400 } - next(createExpressError(err)) + next(utils.createExpressError(err)) return } try { @@ -84,7 +84,7 @@ const query = async function (req, res, next) { res.set(utils.configureLDHeadersFor(matches)) res.json(matches) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) } } @@ -116,9 +116,9 @@ const id = async function (req, res, next) { "message": `No RERUM object with id '${id}'`, "status": 404 } - next(createExpressError(err)) + next(utils.createExpressError(err)) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) } } diff --git a/controllers/delete.js b/controllers/delete.js index 7e2bc644..8002f1cf 100644 --- a/controllers/delete.js +++ b/controllers/delete.js @@ -5,7 +5,7 @@ * @author Claude Sonnet 4, cubap, thehabes */ import { newID, isValidID, db } from '../database/index.js' -import utils, { createExpressError } from '../utils.js' +import utils from '../utils.js' import { getAgentClaim, parseDocumentID, getAllVersions, getAllDescendants } from './utils.js' /** @@ -26,7 +26,7 @@ const deleteObj = async function(req, res, next) { try { id = req.params["_id"] ?? parseDocumentID(JSON.parse(JSON.stringify(req.body))["@id"]) ?? parseDocumentID(JSON.parse(JSON.stringify(req.body))["id"]) } catch(error){ - next(createExpressError(error)) + next(utils.createExpressError(error)) return } let agentRequestingDelete = getAgentClaim(req, next) @@ -34,7 +34,7 @@ const deleteObj = async function(req, res, next) { try { originalObject = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (null !== originalObject) { @@ -58,7 +58,7 @@ const deleteObj = async function(req, res, next) { }) } if (err.status) { - next(createExpressError(err)) + next(utils.createExpressError(err)) return } let preserveID = safe_original["@id"] @@ -76,14 +76,14 @@ const deleteObj = async function(req, res, next) { try { result = await db.replaceOne({ "_id": originalObject["_id"] }, deletedObject) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (result.modifiedCount === 0) { //result didn't error out, the action was not performed. Sometimes, this is a neutral thing. Sometimes it is indicative of an error. err.message = "The original object was not replaced with the deleted object in the database." err.status = 500 - next(createExpressError(err)) + next(utils.createExpressError(err)) return } //204 to say it is deleted and there is nothing in the body @@ -94,12 +94,12 @@ const deleteObj = async function(req, res, next) { //Not sure we can get here, as healHistoryTree might throw and error. err.message = "The history tree for the object being deleted could not be mended." err.status = 500 - next(createExpressError(err)) + next(utils.createExpressError(err)) return } err.message = "No object with this id could be found in RERUM. Cannot delete." err.status = 404 - next(createExpressError(err)) + next(utils.createExpressError(err)) } /** diff --git a/controllers/gog.js b/controllers/gog.js index bf84bc84..78abce04 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -7,7 +7,7 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils, { createExpressError } from '../utils.js' +import utils from '../utils.js' import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' /** @@ -44,7 +44,7 @@ const _gog_fragments_from_manuscript = async function (req, res, next) { }) } if (err.status) { - next(createExpressError(err)) + next(utils.createExpressError(err)) return } try { @@ -138,7 +138,7 @@ const _gog_fragments_from_manuscript = async function (req, res, next) { } catch (error) { console.error(error) - next(createExpressError(error)) + next(utils.createExpressError(error)) } } @@ -176,7 +176,7 @@ const _gog_glosses_from_manuscript = async function (req, res, next) { }) } if (err.status) { - next(createExpressError(err)) + next(utils.createExpressError(err)) return } try { @@ -300,7 +300,7 @@ const _gog_glosses_from_manuscript = async function (req, res, next) { } catch (error) { console.error(error) - next(createExpressError(error)) + next(utils.createExpressError(error)) } } diff --git a/controllers/history.js b/controllers/history.js index c38f089a..651568d8 100644 --- a/controllers/history.js +++ b/controllers/history.js @@ -7,7 +7,7 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils, { createExpressError } from '../utils.js' +import utils from '../utils.js' import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, getAllVersions, getAllAncestors, getAllDescendants } from './utils.js' /** @@ -23,7 +23,7 @@ const since = async function (req, res, next) { try { obj = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (null === obj) { @@ -31,7 +31,7 @@ const since = async function (req, res, next) { message: `Cannot produce a history. There is no object in the database with id '${id}'. Check the URL.`, status: 404 } - next(createExpressError(err)) + next(utils.createExpressError(err)) return } let all = await getAllVersions(obj) @@ -60,7 +60,7 @@ const history = async function (req, res, next) { try { obj = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (null === obj) { @@ -68,7 +68,7 @@ const history = async function (req, res, next) { message: `Cannot produce a history. There is no object in the database with id '${id}'. Check the URL.`, status: 404 } - next(createExpressError(err)) + next(utils.createExpressError(err)) return } let all = await getAllVersions(obj) @@ -103,9 +103,9 @@ const idHeadRequest = async function (req, res, next) { "message": `No RERUM object with id '${id}'`, "status": 404 } - next(createExpressError(err)) + next(utils.createExpressError(err)) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) } } @@ -128,9 +128,9 @@ const queryHeadRequest = async function (req, res, next) { "message": `There are no objects in the database matching the query. Check the request body.`, "status": 404 } - next(createExpressError(err)) + next(utils.createExpressError(err)) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) } } @@ -145,7 +145,7 @@ const sinceHeadRequest = async function (req, res, next) { try { obj = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (null === obj) { @@ -153,7 +153,7 @@ const sinceHeadRequest = async function (req, res, next) { message: `Cannot produce a history. There is no object in the database with id '${id}'. Check the URL.`, status: 404 } - next(createExpressError(err)) + next(utils.createExpressError(err)) return } let all = await getAllVersions(obj) @@ -183,7 +183,7 @@ const historyHeadRequest = async function (req, res, next) { try { obj = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (null === obj) { @@ -191,7 +191,7 @@ const historyHeadRequest = async function (req, res, next) { message: "Cannot produce a history. There is no object in the database with this id. Check the URL.", status: 404 } - next(createExpressError(err)) + next(utils.createExpressError(err)) return } let all = await getAllVersions(obj) diff --git a/controllers/overwrite.js b/controllers/overwrite.js index fbe90608..9c39f53d 100644 --- a/controllers/overwrite.js +++ b/controllers/overwrite.js @@ -7,7 +7,7 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils, { createExpressError } from '../utils.js' +import utils from '../utils.js' import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' /** @@ -29,7 +29,7 @@ const overwrite = async function (req, res, next) { try { originalObject = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (null === originalObject) { @@ -85,7 +85,7 @@ const overwrite = async function (req, res, next) { try { result = await db.replaceOne({ "_id": id }, newObject) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (result.modifiedCount == 0) { @@ -109,7 +109,7 @@ const overwrite = async function (req, res, next) { status: 400 }) } - next(createExpressError(err)) + next(utils.createExpressError(err)) } export { overwrite } diff --git a/controllers/patchSet.js b/controllers/patchSet.js index cb35d230..1d1f0932 100644 --- a/controllers/patchSet.js +++ b/controllers/patchSet.js @@ -7,7 +7,7 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils, { createExpressError } from '../utils.js' +import utils from '../utils.js' import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' /** @@ -32,7 +32,7 @@ const patchSet = async function (req, res, next) { try { originalObject = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (null === originalObject) { @@ -106,7 +106,7 @@ const patchSet = async function (req, res, next) { } catch (error) { //WriteError or WriteConcernError - next(createExpressError(error)) + next(utils.createExpressError(error)) return } } @@ -118,7 +118,7 @@ const patchSet = async function (req, res, next) { status: 400 }) } - next(createExpressError(err)) + next(utils.createExpressError(err)) } export { patchSet } diff --git a/controllers/patchUnset.js b/controllers/patchUnset.js index 340e16a8..d9c8c6e7 100644 --- a/controllers/patchUnset.js +++ b/controllers/patchUnset.js @@ -7,7 +7,7 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils, { createExpressError } from '../utils.js' +import utils from '../utils.js' import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' /** @@ -31,7 +31,7 @@ const patchUnset = async function (req, res, next) { try { originalObject = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (null === originalObject) { @@ -111,7 +111,7 @@ const patchUnset = async function (req, res, next) { } catch (error) { //WriteError or WriteConcernError - next(createExpressError(error)) + next(utils.createExpressError(error)) return } } @@ -123,7 +123,7 @@ const patchUnset = async function (req, res, next) { status: 400 }) } - next(createExpressError(err)) + next(utils.createExpressError(err)) } export { patchUnset } diff --git a/controllers/patchUpdate.js b/controllers/patchUpdate.js index 71ff09f0..4bad4716 100644 --- a/controllers/patchUpdate.js +++ b/controllers/patchUpdate.js @@ -7,7 +7,7 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils, { createExpressError } from '../utils.js' +import utils from '../utils.js' import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' /** @@ -30,7 +30,7 @@ const patchUpdate = async function (req, res, next) { try { originalObject = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (null === originalObject) { @@ -110,7 +110,7 @@ const patchUpdate = async function (req, res, next) { } catch (error) { //WriteError or WriteConcernError - next(createExpressError(error)) + next(utils.createExpressError(error)) return } } @@ -122,7 +122,7 @@ const patchUpdate = async function (req, res, next) { status: 400 }) } - next(createExpressError(err)) + next(utils.createExpressError(err)) } export { patchUpdate } diff --git a/controllers/putUpdate.js b/controllers/putUpdate.js index e6f167b7..590613e9 100644 --- a/controllers/putUpdate.js +++ b/controllers/putUpdate.js @@ -7,7 +7,7 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils, { createExpressError } from '../utils.js' +import utils from '../utils.js' import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' /** @@ -35,7 +35,7 @@ const putUpdate = async function (req, res, next) { try { originalObject = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (null === originalObject) { @@ -83,7 +83,7 @@ const putUpdate = async function (req, res, next) { } catch (error) { //WriteError or WriteConcernError - next(createExpressError(error)) + next(utils.createExpressError(error)) return } } @@ -95,7 +95,7 @@ const putUpdate = async function (req, res, next) { status: 400 }) } - next(createExpressError(err)) + next(utils.createExpressError(err)) } /** @@ -134,7 +134,7 @@ async function _import(req, res, next) { } catch (error) { //MongoServerError from the client has the following properties: index, code, keyPattern, keyValue - next(createExpressError(error)) + next(utils.createExpressError(error)) } } diff --git a/controllers/release.js b/controllers/release.js index d2c47b35..b79a9070 100644 --- a/controllers/release.js +++ b/controllers/release.js @@ -7,7 +7,7 @@ */ import { newID, isValidID, db } from '../database/index.js' -import utils, { createExpressError } from '../utils.js' +import utils from '../utils.js' import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, generateSlugId, establishReleasesTree, healReleasesTree } from './utils.js' /** @@ -29,7 +29,7 @@ const release = async function (req, res, next) { if(req.get("Slug")){ let slug_json = await generateSlugId(req.get("Slug"), next) if(slug_json.code){ - next(createExpressError(slug_json)) + next(utils.createExpressError(slug_json)) return } else{ @@ -42,7 +42,7 @@ const release = async function (req, res, next) { originalObject = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } let safe_original = JSON.parse(JSON.stringify(originalObject)) @@ -68,7 +68,7 @@ const release = async function (req, res, next) { }) } if (err.status) { - next(createExpressError(err)) + next(utils.createExpressError(err)) return } console.log("RELEASE") @@ -103,7 +103,7 @@ const release = async function (req, res, next) { result = await db.replaceOne({ "_id": id }, releasedObject) } catch (error) { - next(createExpressError(error)) + next(utils.createExpressError(error)) return } if (result.modifiedCount == 0) { @@ -125,7 +125,7 @@ const release = async function (req, res, next) { message: "You must provide the id of an object to release. Use /release/id-here or release?_id=id-here.", status: 400 } - next(createExpressError(err)) + next(utils.createExpressError(err)) return } } diff --git a/controllers/search.js b/controllers/search.js index fd8f8a4e..071a1f58 100644 --- a/controllers/search.js +++ b/controllers/search.js @@ -5,7 +5,7 @@ * @author thehabes */ import { db } from '../database/index.js' -import utils, { createExpressError } from '../utils.js' +import utils from '../utils.js' import { idNegotiation } from './utils.js' /** @@ -269,7 +269,7 @@ const searchAsWords = async function (req, res, next) { message: "You did not provide text to search for in the search request.", status: 400 } - next(createExpressError(err)) + next(utils.createExpressError(err)) return } const limit = parseInt(req.query.limit ?? 100) @@ -287,7 +287,7 @@ const searchAsWords = async function (req, res, next) { res.json(results) } catch (error) { console.error(error) - next(createExpressError(error)) + next(utils.createExpressError(error)) } } @@ -357,7 +357,7 @@ const searchAsPhrase = async function (req, res, next) { message: "You did not provide text to search for in the search request.", status: 400 } - next(createExpressError(err)) + next(utils.createExpressError(err)) return } const limit = parseInt(req.query.limit ?? 100) @@ -375,7 +375,7 @@ const searchAsPhrase = async function (req, res, next) { res.json(results) } catch (error) { console.error(error) - next(createExpressError(error)) + next(utils.createExpressError(error)) } } @@ -437,7 +437,7 @@ const searchFuzzily = async function (req, res, next) { message: "You did not provide text to search for in the search request.", status: 400 } - next(createExpressError(err)) + next(utils.createExpressError(err)) return } const limit = parseInt(req.query.limit ?? 100) @@ -455,7 +455,7 @@ const searchFuzzily = async function (req, res, next) { res.json(results) } catch (error) { console.error(error) - next(createExpressError(error)) + next(utils.createExpressError(error)) } } @@ -525,7 +525,7 @@ const searchWildly = async function (req, res, next) { message: "You did not provide text to search for in the search request.", status: 400 } - next(createExpressError(err)) + next(utils.createExpressError(err)) return } // Require wildcards in the search text @@ -534,7 +534,7 @@ const searchWildly = async function (req, res, next) { message: "Wildcards must be used in wildcard search. Use '*' to match any characters or '?' to match a single character.", status: 400 } - next(createExpressError(err)) + next(utils.createExpressError(err)) return } const limit = parseInt(req.query.limit ?? 100) @@ -552,7 +552,7 @@ const searchWildly = async function (req, res, next) { res.json(results) } catch (error) { console.error(error) - next(createExpressError(error)) + next(utils.createExpressError(error)) } } @@ -629,7 +629,7 @@ const searchAlikes = async function (req, res, next) { message: "You must provide a JSON document in the request body to find similar documents.", status: 400 } - next(createExpressError(err)) + next(utils.createExpressError(err)) return } const limit = parseInt(req.query.limit ?? 100) @@ -686,7 +686,7 @@ const searchAlikes = async function (req, res, next) { res.json(results) } catch (error) { console.error(error) - next(createExpressError(error)) + next(utils.createExpressError(error)) } } diff --git a/controllers/utils.js b/controllers/utils.js index 8e5716ad..5df982fa 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -5,7 +5,7 @@ * @author Claude Sonnet 4, cubap, thehabes */ import { newID, isValidID, db } from '../database/index.js' -import { createExpressError } from '../utils.js' +import utils from '../utils.js' const ObjectID = newID @@ -66,7 +66,7 @@ const idNegotiation = function (resBody) { * Check if an object with the proposed custom _id already exists. * If so, this is a 409 conflict. It will be detected downstream if we continue one by returning the proposed Slug. * We can avoid the 409 conflict downstream and return a newly minted ObjectID.toHextString() - * We error out right here with next(createExpressError({"code" : 11000})) + * We error out right here with next(utils.createExpressError({"code" : 11000})) * @param slug_id A proposed _id. * */ @@ -137,7 +137,7 @@ function getAgentClaim(req, next) { "message": "Could not get agent from req.user. Have you registered with RERUM?", "status": 403 } - next(createExpressError(err)) + next(utils.createExpressError(err)) } function parseDocumentID(atID){ diff --git a/rest.js b/rest.js index a35c8768..c0e7be51 100644 --- a/rest.js +++ b/rest.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { createExpressError } from './utils.js' +import utils from './utils.js' /** * This module is used for any REST support functionality. It is used as middleware and so @@ -37,19 +37,19 @@ const jsonContent = function (req, res, next) { const contentType = (req.get("Content-Type") ?? "").toLowerCase() const mimeType = contentType.split(";")[0].trim() if (!mimeType) { - return next(createExpressError({ + return next(utils.createExpressError({ statusCode: 415, statusMessage: `Missing or empty Content-Type header.` })) } if (contentType.includes(",")) { - return next(createExpressError({ + return next(utils.createExpressError({ statusCode: 415, statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` })) } if (mimeType === "application/json" || mimeType === "application/ld+json") return next() - return next(createExpressError({ + return next(utils.createExpressError({ statusCode: 415, statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires application/json or application/ld+json.` })) @@ -66,19 +66,19 @@ const textContent = function (req, res, next) { const contentType = (req.get("Content-Type") ?? "").toLowerCase() const mimeType = contentType.split(";")[0].trim() if (!mimeType) { - return next(createExpressError({ + return next(utils.createExpressError({ statusCode: 415, statusMessage: `Missing or empty Content-Type header.` })) } if (contentType.includes(",")) { - return next(createExpressError({ + return next(utils.createExpressError({ statusCode: 415, statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` })) } if (mimeType === "text/plain") return next() - return next(createExpressError({ + return next(utils.createExpressError({ statusCode: 415, statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires text/plain.` })) @@ -95,19 +95,19 @@ const eitherContent = function (req, res, next) { const contentType = (req.get("Content-Type") ?? "").toLowerCase() const mimeType = contentType.split(";")[0].trim() if (!mimeType) { - return next(createExpressError({ + return next(utils.createExpressError({ statusCode: 415, statusMessage: `Missing or empty Content-Type header.` })) } if (contentType.includes(",")) { - return next(createExpressError({ + return next(utils.createExpressError({ statusCode: 415, statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` })) } if (mimeType === "text/plain" || mimeType === "application/json" || mimeType === "application/ld+json") return next() - return next(createExpressError({ + return next(utils.createExpressError({ statusCode: 415, statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires application/json, application/ld+json, or text/plain.` })) @@ -118,7 +118,7 @@ const eitherContent = function (req, res, next) { * REST is all about communication. The response code and the textual body are particular. * RERUM is all about being clear. It will build custom responses sometimes for certain scenarios, will remaining RESTful. * - * You have likely reached this with a next(createExpressError(err)) call. End here and send the error. + * You have likely reached this with a next(utils.createExpressError(err)) call. End here and send the error. */ const messenger = function (err, req, res, next) { if (res.headersSent) { diff --git a/utils.js b/utils.js index bad9ab33..e007dc72 100644 --- a/utils.js +++ b/utils.js @@ -246,7 +246,7 @@ const configureLastModifiedHeader = function(obj){ * @param {Object} err - An error or object with statusCode/status and statusMessage/message properties * @return {Object} A normalized error object with statusCode and statusMessage */ -export function createExpressError(err) { +function createExpressError(err) { let error = { statusCode: err.statusCode ?? err.status ?? 500, statusMessage: err.statusMessage ?? err.message ?? "There was an error that prevented this request from completing successfully." From f9d4f41e4fcdb23ba384c5b36535bca4ec9b06f6 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 13:28:03 -0500 Subject: [PATCH 31/43] changes while testing and reviewing --- rest.js | 9 ++++++--- routes/patchSet.js | 2 +- routes/patchUnset.js | 2 +- routes/patchUpdate.js | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/rest.js b/rest.js index c0e7be51..26a20f0c 100644 --- a/rest.js +++ b/rest.js @@ -27,7 +27,8 @@ const checkPatchOverrideSupport = function (req, res) { } /** - * Middleware to validate JSON Content-Type headers for endpoints recieving JSON bodies. + * Middleware to verify Content-Type headers for endpoints receiving JSON bodies. + * Responds with a 415 Invalid Media Type for Content-Type headers that are not for JSON bodies. * * @param {Object} req - Express request object * @param {Object} res - Express response object @@ -56,7 +57,8 @@ const jsonContent = function (req, res, next) { } /** - * Middleware to validate Content-Type headers for endpoints recieving textual bodies. + * Middleware to verify Content-Type headers for endpoints receiving textual bodies. + * Responds with a 415 Invalid Media Type for Content-Type headers that are not for textual bodies. * * @param {Object} req - Express request object * @param {Object} res - Express response object @@ -85,7 +87,8 @@ const textContent = function (req, res, next) { } /** - * Middleware to validate Content-Type headers for endpoints recieving either JSON or textual bodies. + * Middleware to verify Content-Type headers for endpoints receiving either JSON or textual bodies. + * Responds with a 415 Invalid Media Type for Content-Type headers that are neither for textual bodies nor JSON bodies. * * @param {Object} req - Express request object * @param {Object} res - Express response object diff --git a/routes/patchSet.js b/routes/patchSet.js index abf23cfd..ce53a768 100644 --- a/routes/patchSet.js +++ b/routes/patchSet.js @@ -7,7 +7,7 @@ import rest from '../rest.js' router.route('/') .patch(auth.checkJwt, rest.jsonContent, controller.patchSet) - .post(auth.checkJwt, (req, res, next) => { + .post(auth.checkJwt, rest.jsonContent, (req, res, next) => { if (rest.checkPatchOverrideSupport(req, res)) { controller.patchSet(req, res, next) } diff --git a/routes/patchUnset.js b/routes/patchUnset.js index b3e56c21..15cf94c2 100644 --- a/routes/patchUnset.js +++ b/routes/patchUnset.js @@ -7,7 +7,7 @@ import rest from '../rest.js' router.route('/') .patch(auth.checkJwt, rest.jsonContent, controller.patchUnset) - .post(auth.checkJwt, (req, res, next) => { + .post(auth.checkJwt, rest.jsonContent, (req, res, next) => { if (rest.checkPatchOverrideSupport(req, res)) { controller.patchUnset(req, res, next) } diff --git a/routes/patchUpdate.js b/routes/patchUpdate.js index 31e26d00..43166ab1 100644 --- a/routes/patchUpdate.js +++ b/routes/patchUpdate.js @@ -8,7 +8,7 @@ import auth from '../auth/index.js' router.route('/') .patch(auth.checkJwt, rest.jsonContent, controller.patchUpdate) - .post(auth.checkJwt, (req, res, next) => { + .post(auth.checkJwt, rest.jsonContent, (req, res, next) => { if (rest.checkPatchOverrideSupport(req, res)) { controller.patchUpdate(req, res, next) } From 555de0940b784fab6bbcdd212e38cc6cbe09fb2d Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 13:54:56 -0500 Subject: [PATCH 32/43] It is about time we clean these old logs out. They are not useful because the endpoint is logged. --- controllers/crud.js | 1 - controllers/overwrite.js | 1 - controllers/patchUnset.js | 1 - controllers/patchUpdate.js | 1 - controllers/putUpdate.js | 2 -- controllers/release.js | 1 - 6 files changed, 7 deletions(-) diff --git a/controllers/crud.js b/controllers/crud.js index 9aff0f17..178f455c 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -43,7 +43,6 @@ const create = async function (req, res, next) { delete provided["@context"] let newObject = Object.assign(context, { "@id": process.env.RERUM_ID_PREFIX + id }, provided, rerumProp, { "_id": id }) - console.log("CREATE") try { let result = await db.insertOne(newObject) res.set(utils.configureWebAnnoHeadersFor(newObject)) diff --git a/controllers/overwrite.js b/controllers/overwrite.js index 9c39f53d..e496e670 100644 --- a/controllers/overwrite.js +++ b/controllers/overwrite.js @@ -23,7 +23,6 @@ const overwrite = async function (req, res, next) { let agentRequestingOverwrite = getAgentClaim(req, next) const receivedID = objectReceived["@id"] ?? objectReceived.id if (receivedID) { - console.log("OVERWRITE") let id = parseDocumentID(receivedID) let originalObject try { diff --git a/controllers/patchUnset.js b/controllers/patchUnset.js index d9c8c6e7..5d8f05f3 100644 --- a/controllers/patchUnset.js +++ b/controllers/patchUnset.js @@ -91,7 +91,6 @@ const patchUnset = async function (req, res, next) { if(_contextid(patchedObject["@context"])) delete patchedObject.id delete patchedObject["@context"] let newObject = Object.assign(context, { "@id": process.env.RERUM_ID_PREFIX + id }, patchedObject, rerumProp, { "_id": id }) - console.log("PATCH UNSET") try { let result = await db.insertOne(newObject) if (alterHistoryNext(originalObject, newObject["@id"])) { diff --git a/controllers/patchUpdate.js b/controllers/patchUpdate.js index 4bad4716..942c61f9 100644 --- a/controllers/patchUpdate.js +++ b/controllers/patchUpdate.js @@ -90,7 +90,6 @@ const patchUpdate = async function (req, res, next) { if(_contextid(patchedObject["@context"])) delete patchedObject.id delete patchedObject["@context"] let newObject = Object.assign(context, { "@id": process.env.RERUM_ID_PREFIX + id }, patchedObject, rerumProp, { "_id": id }) - console.log("PATCH UPDATE") try { let result = await db.insertOne(newObject) if (alterHistoryNext(originalObject, newObject["@id"])) { diff --git a/controllers/putUpdate.js b/controllers/putUpdate.js index 590613e9..3778dcf8 100644 --- a/controllers/putUpdate.js +++ b/controllers/putUpdate.js @@ -63,7 +63,6 @@ const putUpdate = async function (req, res, next) { delete objectReceived["@context"] let newObject = Object.assign(context, { "@id": process.env.RERUM_ID_PREFIX + id }, objectReceived, rerumProp, { "_id": id }) - console.log("UPDATE") try { let result = await db.insertOne(newObject) if (alterHistoryNext(originalObject, newObject["@id"])) { @@ -122,7 +121,6 @@ async function _import(req, res, next) { delete objectReceived["@context"] let newObject = Object.assign(context, { "@id": process.env.RERUM_ID_PREFIX + id }, objectReceived, rerumProp, { "_id": id }) - console.log("IMPORT") try { let result = await db.insertOne(newObject) res.set(utils.configureWebAnnoHeadersFor(newObject)) diff --git a/controllers/release.js b/controllers/release.js index b79a9070..66fe534e 100644 --- a/controllers/release.js +++ b/controllers/release.js @@ -71,7 +71,6 @@ const release = async function (req, res, next) { next(utils.createExpressError(err)) return } - console.log("RELEASE") if (null !== originalObject){ safe_original["__rerum"].isReleased = new Date(Date.now()).toISOString().replace("Z", "") safe_original["__rerum"].releases.replaces = previousReleasedID From e1d8b90881638a1aed0e65abb2775401db4ec93f Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 14:02:14 -0500 Subject: [PATCH 33/43] Don't respond with the full token --- rest.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest.js b/rest.js index 26a20f0c..2ca74d35 100644 --- a/rest.js +++ b/rest.js @@ -155,7 +155,7 @@ If the body is JSON, make sure it is valid JSON.` if (token) { error.message += ` The token provided is Unauthorized. Please check that it is your token and that it is not expired. -Token: ${token} ` +Token: ${token.slice(0, 15)}... ` } else { error.message += ` @@ -168,7 +168,7 @@ like "Authorization: Bearer ". Make sure you have registered at ${process if (token) { error.message += ` You are Forbidden from performing this action. Check your privileges. -Token: ${token}` +Token: ${token.slice(0, 15)}...` } else { //If there was no Token, this would be a 401. If you made it here, you didn't REST. From 1a9c5b21c84fc80e18ddbda02d09f41a5b7cf2e9 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 14:21:46 -0500 Subject: [PATCH 34/43] rename --- rest.js | 8 ++++---- routes/__tests__/contentType.test.js | 18 +++++++++--------- routes/bulkCreate.js | 2 +- routes/bulkUpdate.js | 2 +- routes/create.js | 2 +- routes/overwrite.js | 2 +- routes/patchSet.js | 4 ++-- routes/patchUnset.js | 4 ++-- routes/patchUpdate.js | 4 ++-- routes/putUpdate.js | 2 +- routes/query.js | 2 +- routes/search.js | 4 ++-- 12 files changed, 27 insertions(+), 27 deletions(-) diff --git a/rest.js b/rest.js index 2ca74d35..2fa8a336 100644 --- a/rest.js +++ b/rest.js @@ -34,7 +34,7 @@ const checkPatchOverrideSupport = function (req, res) { * @param {Object} res - Express response object * @param {Function} next - Express next middleware function */ -const jsonContent = function (req, res, next) { +const verifyJsonContentType = function (req, res, next) { const contentType = (req.get("Content-Type") ?? "").toLowerCase() const mimeType = contentType.split(";")[0].trim() if (!mimeType) { @@ -64,7 +64,7 @@ const jsonContent = function (req, res, next) { * @param {Object} res - Express response object * @param {Function} next - Express next middleware function */ -const textContent = function (req, res, next) { +const verifyTextContentType = function (req, res, next) { const contentType = (req.get("Content-Type") ?? "").toLowerCase() const mimeType = contentType.split(";")[0].trim() if (!mimeType) { @@ -94,7 +94,7 @@ const textContent = function (req, res, next) { * @param {Object} res - Express response object * @param {Function} next - Express next middleware function */ -const eitherContent = function (req, res, next) { +const verifyEitherContentType = function (req, res, next) { const contentType = (req.get("Content-Type") ?? "").toLowerCase() const mimeType = contentType.split(";")[0].trim() if (!mimeType) { @@ -208,4 +208,4 @@ It may not have completed at all, and most likely did not complete successfully. res.status(error.status).send(error.message) } -export default { checkPatchOverrideSupport, jsonContent, textContent, eitherContent, messenger } +export default { checkPatchOverrideSupport, verifyJsonContentType, verifyTextContentType, verifyEitherContentType, messenger } diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index fb9a8df6..0e77b992 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -3,7 +3,7 @@ import request from "supertest" import rest from '../../rest.js' /** - * Tests for the Content-Type validation middlewares: jsonContent, textContent, and eitherContent. + * Tests for the Content-Type validation middlewares: verifyJsonContentType, verifyTextContentType, and verifyEitherContentType. * Each middleware is applied per-route rather than as a blanket middleware. */ @@ -13,30 +13,30 @@ routeTester.use(express.json({ type: ["application/json", "application/ld+json"] routeTester.use(express.text()) // JSON-only endpoints (like /api/create, /api/query, /api/update, etc.) -routeTester.post("/json-endpoint", rest.jsonContent, (req, res) => { +routeTester.post("/json-endpoint", rest.verifyJsonContentType, (req, res) => { res.status(200).json({ received: req.body }) }) -routeTester.put("/json-endpoint", rest.jsonContent, (req, res) => { +routeTester.put("/json-endpoint", rest.verifyJsonContentType, (req, res) => { res.status(200).json({ received: req.body }) }) -routeTester.patch("/json-endpoint", rest.jsonContent, (req, res) => { +routeTester.patch("/json-endpoint", rest.verifyJsonContentType, (req, res) => { res.status(200).json({ received: req.body }) }) // Text-only endpoint -routeTester.post("/text-endpoint", rest.textContent, (req, res) => { +routeTester.post("/text-endpoint", rest.verifyTextContentType, (req, res) => { res.status(200).json({ received: req.body }) }) // Either JSON or text endpoint (like /api/search) -routeTester.post("/either-endpoint", rest.eitherContent, (req, res) => { +routeTester.post("/either-endpoint", rest.verifyEitherContentType, (req, res) => { res.status(200).json({ received: req.body }) }) // Error handler matching the app's pattern routeTester.use(rest.messenger) -describe("jsonContent middleware", () => { +describe("verifyJsonContentType middleware", () => { it("accepts application/json", async () => { const response = await request(routeTester) @@ -156,7 +156,7 @@ describe("jsonContent middleware", () => { }) }) -describe("textContent middleware", () => { +describe("verifyTextContentType middleware", () => { it("accepts text/plain", async () => { const response = await request(routeTester) @@ -204,7 +204,7 @@ describe("textContent middleware", () => { }) }) -describe("eitherContent middleware", () => { +describe("verifyEitherContentType middleware", () => { it("accepts application/json", async () => { const response = await request(routeTester) diff --git a/routes/bulkCreate.js b/routes/bulkCreate.js index 86147b76..b4cb49f4 100644 --- a/routes/bulkCreate.js +++ b/routes/bulkCreate.js @@ -8,7 +8,7 @@ import auth from '../auth/index.js' import rest from '../rest.js' router.route('/') - .post(auth.checkJwt, rest.jsonContent, controller.bulkCreate) + .post(auth.checkJwt, rest.verifyJsonContentType, controller.bulkCreate) .all((req, res, next) => { res.statusMessage = 'Improper request method for creating, please use POST.' res.status(405) diff --git a/routes/bulkUpdate.js b/routes/bulkUpdate.js index 6d27988d..293cd113 100644 --- a/routes/bulkUpdate.js +++ b/routes/bulkUpdate.js @@ -8,7 +8,7 @@ import auth from '../auth/index.js' import rest from '../rest.js' router.route('/') - .put(auth.checkJwt, rest.jsonContent, controller.bulkUpdate) + .put(auth.checkJwt, rest.verifyJsonContentType, controller.bulkUpdate) .all((req, res, next) => { res.statusMessage = 'Improper request method for creating, please use PUT.' res.status(405) diff --git a/routes/create.js b/routes/create.js index 3b1d7850..e015d129 100644 --- a/routes/create.js +++ b/routes/create.js @@ -7,7 +7,7 @@ import auth from '../auth/index.js' import rest from '../rest.js' router.route('/') - .post(auth.checkJwt, rest.jsonContent, controller.create) + .post(auth.checkJwt, rest.verifyJsonContentType, controller.create) .all((req, res, next) => { res.statusMessage = 'Improper request method for creating, please use POST.' res.status(405) diff --git a/routes/overwrite.js b/routes/overwrite.js index 42701638..edb8ed06 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -7,7 +7,7 @@ import auth from '../auth/index.js' import rest from '../rest.js' router.route('/') - .put(auth.checkJwt, rest.jsonContent, controller.overwrite) + .put(auth.checkJwt, rest.verifyJsonContentType, controller.overwrite) .all((req, res, next) => { res.statusMessage = 'Improper request method for overwriting, please use PUT to overwrite this object.' res.status(405) diff --git a/routes/patchSet.js b/routes/patchSet.js index ce53a768..2cf4cb52 100644 --- a/routes/patchSet.js +++ b/routes/patchSet.js @@ -6,8 +6,8 @@ import auth from '../auth/index.js' import rest from '../rest.js' router.route('/') - .patch(auth.checkJwt, rest.jsonContent, controller.patchSet) - .post(auth.checkJwt, rest.jsonContent, (req, res, next) => { + .patch(auth.checkJwt, rest.verifyJsonContentType, controller.patchSet) + .post(auth.checkJwt, rest.verifyJsonContentType, (req, res, next) => { if (rest.checkPatchOverrideSupport(req, res)) { controller.patchSet(req, res, next) } diff --git a/routes/patchUnset.js b/routes/patchUnset.js index 15cf94c2..4aa5ee04 100644 --- a/routes/patchUnset.js +++ b/routes/patchUnset.js @@ -6,8 +6,8 @@ import auth from '../auth/index.js' import rest from '../rest.js' router.route('/') - .patch(auth.checkJwt, rest.jsonContent, controller.patchUnset) - .post(auth.checkJwt, rest.jsonContent, (req, res, next) => { + .patch(auth.checkJwt, rest.verifyJsonContentType, controller.patchUnset) + .post(auth.checkJwt, rest.verifyJsonContentType, (req, res, next) => { if (rest.checkPatchOverrideSupport(req, res)) { controller.patchUnset(req, res, next) } diff --git a/routes/patchUpdate.js b/routes/patchUpdate.js index 43166ab1..2d13564c 100644 --- a/routes/patchUpdate.js +++ b/routes/patchUpdate.js @@ -7,8 +7,8 @@ import rest from '../rest.js' import auth from '../auth/index.js' router.route('/') - .patch(auth.checkJwt, rest.jsonContent, controller.patchUpdate) - .post(auth.checkJwt, rest.jsonContent, (req, res, next) => { + .patch(auth.checkJwt, rest.verifyJsonContentType, controller.patchUpdate) + .post(auth.checkJwt, rest.verifyJsonContentType, (req, res, next) => { if (rest.checkPatchOverrideSupport(req, res)) { controller.patchUpdate(req, res, next) } diff --git a/routes/putUpdate.js b/routes/putUpdate.js index a87636da..88cc93f4 100644 --- a/routes/putUpdate.js +++ b/routes/putUpdate.js @@ -7,7 +7,7 @@ import auth from '../auth/index.js' import rest from '../rest.js' router.route('/') - .put(auth.checkJwt, rest.jsonContent, controller.putUpdate) + .put(auth.checkJwt, rest.verifyJsonContentType, controller.putUpdate) .all((req, res, next) => { res.statusMessage = 'Improper request method for updating, please use PUT to update this object.' res.status(405) diff --git a/routes/query.js b/routes/query.js index 181cb19e..5be0c5ba 100644 --- a/routes/query.js +++ b/routes/query.js @@ -5,7 +5,7 @@ import controller from '../db-controller.js' import rest from '../rest.js' router.route('/') - .post(rest.jsonContent, controller.query) + .post(rest.verifyJsonContentType, controller.query) .head(controller.queryHeadRequest) .all((req, res, next) => { res.statusMessage = 'Improper request method for requesting objects with matching properties. Please use POST.' diff --git a/routes/search.js b/routes/search.js index e90be615..9b9948ca 100644 --- a/routes/search.js +++ b/routes/search.js @@ -4,7 +4,7 @@ import controller from '../db-controller.js' import rest from '../rest.js' router.route('/') - .post(rest.eitherContent, controller.searchAsWords) + .post(rest.verifyEitherContentType, controller.searchAsWords) .all((req, res, next) => { res.statusMessage = 'Improper request method for search. Please use POST.' res.status(405) @@ -12,7 +12,7 @@ router.route('/') }) router.route('/phrase') - .post(rest.eitherContent, controller.searchAsPhrase) + .post(rest.verifyEitherContentType, controller.searchAsPhrase) .all((req, res, next) => { res.statusMessage = 'Improper request method for search. Please use POST.' res.status(405) From 01d3359adbcc20591d52332cde9d1ebb328e1987 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 14:33:24 -0500 Subject: [PATCH 35/43] Changes while testing and reviewing --- rest.js | 6 +++--- routes/__tests__/contentType.test.js | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/rest.js b/rest.js index 2fa8a336..9e047e76 100644 --- a/rest.js +++ b/rest.js @@ -43,13 +43,13 @@ const verifyJsonContentType = function (req, res, next) { statusMessage: `Missing or empty Content-Type header.` })) } + if (mimeType === "application/json" || mimeType === "application/ld+json") return next() if (contentType.includes(",")) { return next(utils.createExpressError({ statusCode: 415, statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` })) } - if (mimeType === "application/json" || mimeType === "application/ld+json") return next() return next(utils.createExpressError({ statusCode: 415, statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires application/json or application/ld+json.` @@ -73,13 +73,13 @@ const verifyTextContentType = function (req, res, next) { statusMessage: `Missing or empty Content-Type header.` })) } + if (mimeType === "text/plain") return next() if (contentType.includes(",")) { return next(utils.createExpressError({ statusCode: 415, statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` })) } - if (mimeType === "text/plain") return next() return next(utils.createExpressError({ statusCode: 415, statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires text/plain.` @@ -103,13 +103,13 @@ const verifyEitherContentType = function (req, res, next) { statusMessage: `Missing or empty Content-Type header.` })) } + if (mimeType === "text/plain" || mimeType === "application/json" || mimeType === "application/ld+json") return next() if (contentType.includes(",")) { return next(utils.createExpressError({ statusCode: 415, statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` })) } - if (mimeType === "text/plain" || mimeType === "application/json" || mimeType === "application/ld+json") return next() return next(utils.createExpressError({ statusCode: 415, statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires application/json, application/ld+json, or text/plain.` diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index 0e77b992..7e524a0c 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -146,13 +146,14 @@ describe("verifyJsonContentType middleware", () => { expect(response.text).toContain("Multiple Content-Type values are not allowed") }) - it("returns 415 for valid type smuggled via comma after charset", async () => { + it("accepts valid MIME type even with trailing comma-separated parameter noise", async () => { + // The MIME type is application/json (valid), so the request passes. + // The comma after charset is in the parameter portion, not the MIME type. const response = await request(routeTester) .post("/json-endpoint") .set("Content-Type", "application/json; charset=utf-8, text/plain") .send('{"test":"data"}') - expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") + expect(response.statusCode).toBe(200) }) }) From 8d106d07136ca24c2511e9ef50daf2e6fbe34fcd Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 14:41:37 -0500 Subject: [PATCH 36/43] I like the way it errors this way better --- rest.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rest.js b/rest.js index 9e047e76..2fa8a336 100644 --- a/rest.js +++ b/rest.js @@ -43,13 +43,13 @@ const verifyJsonContentType = function (req, res, next) { statusMessage: `Missing or empty Content-Type header.` })) } - if (mimeType === "application/json" || mimeType === "application/ld+json") return next() if (contentType.includes(",")) { return next(utils.createExpressError({ statusCode: 415, statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` })) } + if (mimeType === "application/json" || mimeType === "application/ld+json") return next() return next(utils.createExpressError({ statusCode: 415, statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires application/json or application/ld+json.` @@ -73,13 +73,13 @@ const verifyTextContentType = function (req, res, next) { statusMessage: `Missing or empty Content-Type header.` })) } - if (mimeType === "text/plain") return next() if (contentType.includes(",")) { return next(utils.createExpressError({ statusCode: 415, statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` })) } + if (mimeType === "text/plain") return next() return next(utils.createExpressError({ statusCode: 415, statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires text/plain.` @@ -103,13 +103,13 @@ const verifyEitherContentType = function (req, res, next) { statusMessage: `Missing or empty Content-Type header.` })) } - if (mimeType === "text/plain" || mimeType === "application/json" || mimeType === "application/ld+json") return next() if (contentType.includes(",")) { return next(utils.createExpressError({ statusCode: 415, statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` })) } + if (mimeType === "text/plain" || mimeType === "application/json" || mimeType === "application/ld+json") return next() return next(utils.createExpressError({ statusCode: 415, statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires application/json, application/ld+json, or text/plain.` From 0e6101dddd96932a8eddf06cf3ecead9a304f1d3 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 14:47:30 -0500 Subject: [PATCH 37/43] Update for tests --- routes/__tests__/bulkCreate.test.js | 2 +- routes/__tests__/bulkUpdate.test.js | 2 +- routes/__tests__/contentType.test.js | 9 +++++---- routes/__tests__/create.test.js | 2 +- routes/__tests__/delete.test.js | 2 +- routes/__tests__/history.test.js | 2 +- routes/__tests__/id.test.js | 2 +- routes/__tests__/overwrite-optimistic-locking.test.txt | 2 +- routes/__tests__/patch.test.js | 2 +- routes/__tests__/query.test.js | 2 +- routes/__tests__/release.test.js | 2 +- routes/__tests__/set.test.js | 2 +- routes/__tests__/since.test.js | 2 +- routes/__tests__/unset.test.js | 2 +- routes/__tests__/update.test.js | 2 +- 15 files changed, 19 insertions(+), 18 deletions(-) diff --git a/routes/__tests__/bulkCreate.test.js b/routes/__tests__/bulkCreate.test.js index 129ee9ac..05dbf29f 100644 --- a/routes/__tests__/bulkCreate.test.js +++ b/routes/__tests__/bulkCreate.test.js @@ -12,7 +12,7 @@ const addAuth = (req, res, next) => { } const routeTester = new express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // Mount our own /bulkCreate route without auth that will use controller.bulkCreate routeTester.use("/bulkCreate", [addAuth, controller.bulkCreate]) diff --git a/routes/__tests__/bulkUpdate.test.js b/routes/__tests__/bulkUpdate.test.js index f7efe859..b9b619d6 100644 --- a/routes/__tests__/bulkUpdate.test.js +++ b/routes/__tests__/bulkUpdate.test.js @@ -12,7 +12,7 @@ const addAuth = (req, res, next) => { } const routeTester = new express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // Mount our own /bulkCreate route without auth that will use controller.bulkCreate routeTester.use("/bulkUpdate", [addAuth, controller.bulkUpdate]) diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index 7e524a0c..e21ae7a7 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -146,14 +146,15 @@ describe("verifyJsonContentType middleware", () => { expect(response.text).toContain("Multiple Content-Type values are not allowed") }) - it("accepts valid MIME type even with trailing comma-separated parameter noise", async () => { - // The MIME type is application/json (valid), so the request passes. - // The comma after charset is in the parameter portion, not the MIME type. + it("returns 415 for comma-injected Content-Type parameter", async () => { + // Even though the MIME type portion is valid, the comma in the full header + // is rejected to prevent Content-Type smuggling via parameter injection. const response = await request(routeTester) .post("/json-endpoint") .set("Content-Type", "application/json; charset=utf-8, text/plain") .send('{"test":"data"}') - expect(response.statusCode).toBe(200) + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") }) }) diff --git a/routes/__tests__/create.test.js b/routes/__tests__/create.test.js index 17197599..3c922587 100644 --- a/routes/__tests__/create.test.js +++ b/routes/__tests__/create.test.js @@ -13,7 +13,7 @@ const addAuth = (req, res, next) => { } const routeTester = new express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // Mount our own /create route without auth that will use controller.create routeTester.use("/create", [addAuth, controller.create]) diff --git a/routes/__tests__/delete.test.js b/routes/__tests__/delete.test.js index 659d2deb..964655a3 100644 --- a/routes/__tests__/delete.test.js +++ b/routes/__tests__/delete.test.js @@ -12,7 +12,7 @@ const addAuth = (req, res, next) => { } const routeTester = new express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // FIXME here we need to create something to delete in order to test this route. routeTester.use("/create", [addAuth, controller.create]) diff --git a/routes/__tests__/history.test.js b/routes/__tests__/history.test.js index 5ea1117e..ffcff3e3 100644 --- a/routes/__tests__/history.test.js +++ b/routes/__tests__/history.test.js @@ -7,7 +7,7 @@ import request from "supertest" import controller from '../../db-controller.js' const routeTester = new express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // Mount our own /history route without auth that will use controller.history routeTester.use("/history/:_id", controller.history) diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index e081cd7c..b5957744 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -6,7 +6,7 @@ import request from "supertest" import controller from '../../db-controller.js' const routeTester = new express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // Mount our own /id route without auth that will use controller.id routeTester.use("/id/:_id", controller.id) diff --git a/routes/__tests__/overwrite-optimistic-locking.test.txt b/routes/__tests__/overwrite-optimistic-locking.test.txt index 771667f7..91d4f771 100644 --- a/routes/__tests__/overwrite-optimistic-locking.test.txt +++ b/routes/__tests__/overwrite-optimistic-locking.test.txt @@ -25,7 +25,7 @@ const addAuth = (req, res, next) => { // Create a test Express app const routeTester = express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // Mount our routes routeTester.use('/overwrite', [addAuth, controller.overwrite]) diff --git a/routes/__tests__/patch.test.js b/routes/__tests__/patch.test.js index 1c63725a..319ef743 100644 --- a/routes/__tests__/patch.test.js +++ b/routes/__tests__/patch.test.js @@ -13,7 +13,7 @@ const addAuth = (req, res, next) => { } const routeTester = new express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // Mount our own /patch route without auth that will use controller.patch routeTester.use("/patch", [addAuth, controller.patchUpdate]) diff --git a/routes/__tests__/query.test.js b/routes/__tests__/query.test.js index 6a4c4751..8b494836 100644 --- a/routes/__tests__/query.test.js +++ b/routes/__tests__/query.test.js @@ -6,7 +6,7 @@ import request from "supertest" import controller from '../../db-controller.js' const routeTester = new express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // Mount our own /query route without auth that will use controller.query routeTester.use("/query", controller.query) diff --git a/routes/__tests__/release.test.js b/routes/__tests__/release.test.js index 49c028f2..477b2b6a 100644 --- a/routes/__tests__/release.test.js +++ b/routes/__tests__/release.test.js @@ -12,7 +12,7 @@ const addAuth = (req, res, next) => { } const routeTester = new express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // FIXME here we need to create something to release in order to test this route. routeTester.use("/create", [addAuth, controller.create]) diff --git a/routes/__tests__/set.test.js b/routes/__tests__/set.test.js index dd757cc7..5c4af116 100644 --- a/routes/__tests__/set.test.js +++ b/routes/__tests__/set.test.js @@ -14,7 +14,7 @@ const addAuth = (req, res, next) => { } const routeTester = new express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // Mount our own /create route without auth that will use controller.create routeTester.use("/set", [addAuth, controller.patchSet]) diff --git a/routes/__tests__/since.test.js b/routes/__tests__/since.test.js index 3a61c2e0..c8b59213 100644 --- a/routes/__tests__/since.test.js +++ b/routes/__tests__/since.test.js @@ -7,7 +7,7 @@ import request from "supertest" import controller from '../../db-controller.js' const routeTester = new express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // Mount our own /create route without auth that will use controller.history routeTester.use("/since/:_id", controller.since) diff --git a/routes/__tests__/unset.test.js b/routes/__tests__/unset.test.js index 96e136a8..456da795 100644 --- a/routes/__tests__/unset.test.js +++ b/routes/__tests__/unset.test.js @@ -14,7 +14,7 @@ const addAuth = (req, res, next) => { } const routeTester = new express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // Mount our own /create route without auth that will use controller.create routeTester.use("/unset", [addAuth, controller.patchUnset]) diff --git a/routes/__tests__/update.test.js b/routes/__tests__/update.test.js index ae0b36ed..67ae5318 100644 --- a/routes/__tests__/update.test.js +++ b/routes/__tests__/update.test.js @@ -13,7 +13,7 @@ const addAuth = (req, res, next) => { } const routeTester = new express() -routeTester.use(express.json()) +routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // Mount our own /create route without auth that will use controller.create routeTester.use("/update", [addAuth, controller.putUpdate]) From c283859e5b8e8b0d2b10f04176d67b7bf8674929 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 14:54:56 -0500 Subject: [PATCH 38/43] yikes bad not === check --- controllers/gog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/gog.js b/controllers/gog.js index 78abce04..7bc72d17 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -162,7 +162,7 @@ const _gog_glosses_from_manuscript = async function (req, res, next) { const skip = parseInt(req.query.skip ?? 0) let err = { message: `` } // This request can only be made my Gallery of Glosses production apps. - if (!agentID === "61043ad4ffce846a83e700dd") { + if (agentID !== "61043ad4ffce846a83e700dd") { err = Object.assign(err, { message: `Only the Gallery of Glosses can make this request.`, status: 403 From f3cb69feb2ac4d867a2b7cbacc9a4c1ce14fdb56 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 16:11:08 -0500 Subject: [PATCH 39/43] catch comma and semicolon smugglers --- rest.js | 22 ++++++++++++++--- routes/__tests__/contentType.test.js | 36 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/rest.js b/rest.js index 2fa8a336..4539c1b3 100644 --- a/rest.js +++ b/rest.js @@ -26,6 +26,22 @@ const checkPatchOverrideSupport = function (req, res) { return undefined !== override && override === "PATCH" } +/** + * Detects multiple MIME types smuggled into a single Content-Type header. + * Catches both comma-separated types (e.g., "application/json, text/plain") + * and semicolon-smuggled types (e.g., "application/json; text/plain"). + * Per RFC 7231/2045, semicolons delimit parameters which must be key=value pairs. + * A segment without '=' after a semicolon is a bare token, not a valid parameter. + * + * @param {string} contentType - Lowercased Content-Type header value + * @returns {boolean} True if multiple MIME types are detected + */ +const hasMultipleContentTypes = (contentType) => { + if (contentType.includes(",")) return true + const segments = contentType.split(";") + return segments.slice(1).some(segment => !segment.trim().includes("=")) +} + /** * Middleware to verify Content-Type headers for endpoints receiving JSON bodies. * Responds with a 415 Invalid Media Type for Content-Type headers that are not for JSON bodies. @@ -43,7 +59,7 @@ const verifyJsonContentType = function (req, res, next) { statusMessage: `Missing or empty Content-Type header.` })) } - if (contentType.includes(",")) { + if (hasMultipleContentTypes(contentType)) { return next(utils.createExpressError({ statusCode: 415, statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` @@ -73,7 +89,7 @@ const verifyTextContentType = function (req, res, next) { statusMessage: `Missing or empty Content-Type header.` })) } - if (contentType.includes(",")) { + if (hasMultipleContentTypes(contentType)) { return next(utils.createExpressError({ statusCode: 415, statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` @@ -103,7 +119,7 @@ const verifyEitherContentType = function (req, res, next) { statusMessage: `Missing or empty Content-Type header.` })) } - if (contentType.includes(",")) { + if (hasMultipleContentTypes(contentType)) { return next(utils.createExpressError({ statusCode: 415, statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index e21ae7a7..816c9ae4 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -156,6 +156,24 @@ describe("verifyJsonContentType middleware", () => { expect(response.statusCode).toBe(415) expect(response.text).toContain("Multiple Content-Type values are not allowed") }) + + it("returns 415 for semicolon-smuggled MIME type", async () => { + const response = await request(routeTester) + .post("/json-endpoint") + .set("Content-Type", "application/json; text/plain") + .send('{"test":"data"}') + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") + }) + + it("returns 415 for semicolon-smuggled MIME type with valid parameter", async () => { + const response = await request(routeTester) + .post("/json-endpoint") + .set("Content-Type", "application/json; charset=utf-8; text/plain") + .send('{"test":"data"}') + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") + }) }) describe("verifyTextContentType middleware", () => { @@ -204,6 +222,15 @@ describe("verifyTextContentType middleware", () => { expect(response.statusCode).toBe(415) expect(response.text).toContain("Multiple Content-Type values are not allowed") }) + + it("returns 415 for semicolon-smuggled MIME type", async () => { + const response = await request(routeTester) + .post("/text-endpoint") + .set("Content-Type", "text/plain; application/json") + .send("hello") + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") + }) }) describe("verifyEitherContentType middleware", () => { @@ -262,4 +289,13 @@ describe("verifyEitherContentType middleware", () => { expect(response.statusCode).toBe(415) expect(response.text).toContain("Multiple Content-Type values are not allowed") }) + + it("returns 415 for semicolon-smuggled MIME type", async () => { + const response = await request(routeTester) + .post("/either-endpoint") + .set("Content-Type", "application/json; text/plain") + .send('{"test":"data"}') + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") + }) }) From 822b602a6f469445615c407ab657cb4a2ff13488 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 25 Mar 2026 10:14:15 -0500 Subject: [PATCH 40/43] there is a valid version of the header with a comma. Make sure 415 doesn't occur for a valid header. --- rest.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest.js b/rest.js index 4539c1b3..0c33cdc1 100644 --- a/rest.js +++ b/rest.js @@ -37,8 +37,8 @@ const checkPatchOverrideSupport = function (req, res) { * @returns {boolean} True if multiple MIME types are detected */ const hasMultipleContentTypes = (contentType) => { - if (contentType.includes(",")) return true const segments = contentType.split(";") + if (segments[0].includes(",")) return true return segments.slice(1).some(segment => !segment.trim().includes("=")) } From 7d7046a7b4573c1392e52e54567094530c4cce84 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 25 Mar 2026 11:24:39 -0500 Subject: [PATCH 41/43] changes while testing --- rest.js | 61 ++++----- routes/__tests__/contentType.test.js | 183 +++++++++++---------------- 2 files changed, 99 insertions(+), 145 deletions(-) diff --git a/rest.js b/rest.js index 0c33cdc1..38cfe71f 100644 --- a/rest.js +++ b/rest.js @@ -28,18 +28,33 @@ const checkPatchOverrideSupport = function (req, res) { /** * Detects multiple MIME types smuggled into a single Content-Type header. - * Catches both comma-separated types (e.g., "application/json, text/plain") - * and semicolon-smuggled types (e.g., "application/json; text/plain"). - * Per RFC 7231/2045, semicolons delimit parameters which must be key=value pairs. - * A segment without '=' after a semicolon is a bare token, not a valid parameter. - * + * The following are the cases that should result in a 415 (not a 500) + + - application/json text/plain + - application/json, text/plain + - text/plain; application/json + - text/plain; a=b, application/json + - application/json; a=b; text/plain; + - application/json; a=b text/plain; + - application/json; charset=utf-8, text/plain + * @param {string} contentType - Lowercased Content-Type header value * @returns {boolean} True if multiple MIME types are detected */ const hasMultipleContentTypes = (contentType) => { const segments = contentType.split(";") - if (segments[0].includes(",")) return true - return segments.slice(1).some(segment => !segment.trim().includes("=")) + const mimeSegment = segments[0].trim() + // No commas or spaces allowed in MIME types + if (mimeSegment.includes(",") || mimeSegment.includes(" ")) return true + // Parameter values are tokens (no spaces/commas) or quoted strings per RFC 2045. + // Commas or spaces outside quotes indicate a smuggled MIME type. + return segments.slice(1).some(segment => { + const trimmed = segment.trim() + if (!trimmed.includes("=")) return true + const withoutQuoted = trimmed.replace(/"[^"]*"/g, "") + if (withoutQuoted.includes(",") || withoutQuoted.includes(" ")) return true + return false + }) } /** @@ -72,36 +87,6 @@ const verifyJsonContentType = function (req, res, next) { })) } -/** - * Middleware to verify Content-Type headers for endpoints receiving textual bodies. - * Responds with a 415 Invalid Media Type for Content-Type headers that are not for textual bodies. - * - * @param {Object} req - Express request object - * @param {Object} res - Express response object - * @param {Function} next - Express next middleware function - */ -const verifyTextContentType = function (req, res, next) { - const contentType = (req.get("Content-Type") ?? "").toLowerCase() - const mimeType = contentType.split(";")[0].trim() - if (!mimeType) { - return next(utils.createExpressError({ - statusCode: 415, - statusMessage: `Missing or empty Content-Type header.` - })) - } - if (hasMultipleContentTypes(contentType)) { - return next(utils.createExpressError({ - statusCode: 415, - statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` - })) - } - if (mimeType === "text/plain") return next() - return next(utils.createExpressError({ - statusCode: 415, - statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires text/plain.` - })) -} - /** * Middleware to verify Content-Type headers for endpoints receiving either JSON or textual bodies. * Responds with a 415 Invalid Media Type for Content-Type headers that are neither for textual bodies nor JSON bodies. @@ -224,4 +209,4 @@ It may not have completed at all, and most likely did not complete successfully. res.status(error.status).send(error.message) } -export default { checkPatchOverrideSupport, verifyJsonContentType, verifyTextContentType, verifyEitherContentType, messenger } +export default { checkPatchOverrideSupport, verifyJsonContentType, verifyEitherContentType, messenger } diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index 816c9ae4..ded0bdcc 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -1,12 +1,29 @@ +/** + * Tests for the Content-Type validation middlewares verifyJsonContentType and verifyEitherContentType. + * The following are examples of good Content-Type headers that should not result in a 415 + + - application/ld+json + - text/plain; a="b,c" + - application/json; a="b,c"; xy=z + * + * The following are the cases that should result in a 415 (not a 500) + + - application/json text/plain + - application/json, text/plain + - text/plain; application/json + - text/plain; a=b, application/json + - application/json; a=b; text/plain; + - application/json; charset=utf-8, text/plain + + * If a request contains more than one Content-Type header, that should also result in a 415. + * + * @author thehabes + */ + import express from "express" import request from "supertest" import rest from '../../rest.js' -/** - * Tests for the Content-Type validation middlewares: verifyJsonContentType, verifyTextContentType, and verifyEitherContentType. - * Each middleware is applied per-route rather than as a blanket middleware. - */ - // Set up a minimal Express app mirroring the real app's body parsers const routeTester = express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) @@ -16,20 +33,9 @@ routeTester.use(express.text()) routeTester.post("/json-endpoint", rest.verifyJsonContentType, (req, res) => { res.status(200).json({ received: req.body }) }) -routeTester.put("/json-endpoint", rest.verifyJsonContentType, (req, res) => { - res.status(200).json({ received: req.body }) -}) -routeTester.patch("/json-endpoint", rest.verifyJsonContentType, (req, res) => { - res.status(200).json({ received: req.body }) -}) - -// Text-only endpoint -routeTester.post("/text-endpoint", rest.verifyTextContentType, (req, res) => { - res.status(200).json({ received: req.body }) -}) // Either JSON or text endpoint (like /api/search) -routeTester.post("/either-endpoint", rest.verifyEitherContentType, (req, res) => { +routeTester.post("/json-or-text-endpoint", rest.verifyEitherContentType, (req, res) => { res.status(200).json({ received: req.body }) }) @@ -83,24 +89,6 @@ describe("verifyJsonContentType middleware", () => { expect(response.body.received.test).toBe("casing") }) - it("accepts application/json on PUT", async () => { - const response = await request(routeTester) - .put("/json-endpoint") - .set("Content-Type", "application/json") - .send({ test: "put-data" }) - expect(response.statusCode).toBe(200) - expect(response.body.received.test).toBe("put-data") - }) - - it("accepts application/json on PATCH", async () => { - const response = await request(routeTester) - .patch("/json-endpoint") - .set("Content-Type", "application/json") - .send({ test: "patch-data" }) - expect(response.statusCode).toBe(200) - expect(response.body.received.test).toBe("patch-data") - }) - it("returns 415 for missing Content-Type", async () => { const response = await request(routeTester) .post("/json-endpoint") @@ -119,22 +107,13 @@ describe("verifyJsonContentType middleware", () => { expect(response.text).toContain("Unsupported Content-Type") }) - it("returns 415 for text/plain on PUT", async () => { - const response = await request(routeTester) - .put("/json-endpoint") - .set("Content-Type", "text/plain") - .send("some text") - expect(response.statusCode).toBe(415) - expect(response.text).toContain("Unsupported Content-Type") - }) - - it("returns 415 for application/xml", async () => { + it("returns 415 for space-separated multiple Content-Type values", async () => { const response = await request(routeTester) .post("/json-endpoint") - .set("Content-Type", "application/xml") - .send("") + .set("Content-Type", "application/json text/plain") + .send('{"test":"data"}') expect(response.statusCode).toBe(415) - expect(response.text).toContain("Unsupported Content-Type") + expect(response.text).toContain("Multiple Content-Type values are not allowed") }) it("returns 415 for comma-separated multiple Content-Type values", async () => { @@ -174,60 +153,12 @@ describe("verifyJsonContentType middleware", () => { expect(response.statusCode).toBe(415) expect(response.text).toContain("Multiple Content-Type values are not allowed") }) -}) -describe("verifyTextContentType middleware", () => { - - it("accepts text/plain", async () => { + it("returns 415 for space-smuggled MIME type after valid parameter", async () => { const response = await request(routeTester) - .post("/text-endpoint") - .set("Content-Type", "text/plain") - .send("hello world") - expect(response.statusCode).toBe(200) - expect(response.body.received).toBe("hello world") - }) - - it("accepts text/plain with charset parameter", async () => { - const response = await request(routeTester) - .post("/text-endpoint") - .set("Content-Type", "text/plain; charset=utf-8") - .send("hello charset") - expect(response.statusCode).toBe(200) - }) - - it("returns 415 for missing Content-Type", async () => { - const response = await request(routeTester) - .post("/text-endpoint") - .unset("Content-Type") - .send(Buffer.from("hello")) - expect(response.statusCode).toBe(415) - expect(response.text).toContain("Missing or empty Content-Type header") - }) - - it("returns 415 for application/json", async () => { - const response = await request(routeTester) - .post("/text-endpoint") - .set("Content-Type", "application/json") - .send({ test: "data" }) - expect(response.statusCode).toBe(415) - expect(response.text).toContain("Unsupported Content-Type") - expect(response.text).toContain("text/plain") - }) - - it("returns 415 for comma-separated multiple Content-Type values", async () => { - const response = await request(routeTester) - .post("/text-endpoint") - .set("Content-Type", "text/plain, application/json") - .send("hello") - expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") - }) - - it("returns 415 for semicolon-smuggled MIME type", async () => { - const response = await request(routeTester) - .post("/text-endpoint") - .set("Content-Type", "text/plain; application/json") - .send("hello") + .post("/json-endpoint") + .set("Content-Type", "application/json; a=b; c=d text/plain") + .send('{"test":"data"}') expect(response.statusCode).toBe(415) expect(response.text).toContain("Multiple Content-Type values are not allowed") }) @@ -237,7 +168,7 @@ describe("verifyEitherContentType middleware", () => { it("accepts application/json", async () => { const response = await request(routeTester) - .post("/either-endpoint") + .post("/json-or-text-endpoint") .set("Content-Type", "application/json") .send({ searchText: "hello" }) expect(response.statusCode).toBe(200) @@ -246,7 +177,7 @@ describe("verifyEitherContentType middleware", () => { it("accepts application/ld+json", async () => { const response = await request(routeTester) - .post("/either-endpoint") + .post("/json-or-text-endpoint") .set("Content-Type", "application/ld+json") // Must stringify manually; supertest's .send(object) would override Content-Type to application/json .send(JSON.stringify({ "@context": "http://example.org" })) @@ -256,7 +187,7 @@ describe("verifyEitherContentType middleware", () => { it("accepts text/plain", async () => { const response = await request(routeTester) - .post("/either-endpoint") + .post("/json-or-text-endpoint") .set("Content-Type", "text/plain") .send("search terms") expect(response.statusCode).toBe(200) @@ -265,7 +196,7 @@ describe("verifyEitherContentType middleware", () => { it("returns 415 for missing Content-Type", async () => { const response = await request(routeTester) - .post("/either-endpoint") + .post("/json-or-text-endpoint") .unset("Content-Type") .send(Buffer.from("hello")) expect(response.statusCode).toBe(415) @@ -274,28 +205,66 @@ describe("verifyEitherContentType middleware", () => { it("returns 415 for application/xml", async () => { const response = await request(routeTester) - .post("/either-endpoint") + .post("/json-or-text-endpoint") .set("Content-Type", "application/xml") .send("") expect(response.statusCode).toBe(415) expect(response.text).toContain("Unsupported Content-Type") }) + it("returns 415 for space-separated multiple Content-Type values", async () => { + const response = await request(routeTester) + .post("/json-or-text-endpoint") + .set("Content-Type", "application/json text/plain") + .send('{"test":"data"}') + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") + }) + it("returns 415 for comma-separated multiple Content-Type values", async () => { const response = await request(routeTester) - .post("/either-endpoint") + .post("/json-or-text-endpoint") .set("Content-Type", "application/json, text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) expect(response.text).toContain("Multiple Content-Type values are not allowed") }) + it("returns 415 for comma-injected Content-Type parameter", async () => { + // Even though the MIME type portion is valid, the comma in the full header + // is rejected to prevent Content-Type smuggling via parameter injection. + const response = await request(routeTester) + .post("/json-or-text-endpoint") + .set("Content-Type", "application/json; charset=utf-8, text/plain") + .send('{"test":"data"}') + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") + }) + it("returns 415 for semicolon-smuggled MIME type", async () => { const response = await request(routeTester) - .post("/either-endpoint") + .post("/json-or-text-endpoint") .set("Content-Type", "application/json; text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) expect(response.text).toContain("Multiple Content-Type values are not allowed") }) + + it("returns 415 for semicolon-smuggled MIME type with valid parameter", async () => { + const response = await request(routeTester) + .post("/json-or-text-endpoint") + .set("Content-Type", "application/json; charset=utf-8; text/plain") + .send('{"test":"data"}') + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") + }) + + it("returns 415 for space-smuggled MIME type after valid parameter", async () => { + const response = await request(routeTester) + .post("/json-or-text-endpoint") + .set("Content-Type", "application/json; a=b; c=d text/plain") + .send('{"test":"data"}') + expect(response.statusCode).toBe(415) + expect(response.text).toContain("Multiple Content-Type values are not allowed") + }) }) From e29862883abdd4b6522ba4188d30396a03854539 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 25 Mar 2026 11:40:23 -0500 Subject: [PATCH 42/43] changes while testing --- routes/__tests__/contentType.test.js | 42 +++++++++++++--------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index ded0bdcc..bca925e6 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -50,7 +50,6 @@ describe("verifyJsonContentType middleware", () => { .set("Content-Type", "application/json") .send({ test: "data" }) expect(response.statusCode).toBe(200) - expect(response.body.received.test).toBe("data") }) it("accepts application/ld+json", async () => { @@ -60,7 +59,6 @@ describe("verifyJsonContentType middleware", () => { // Must stringify manually; supertest's .send(object) would override Content-Type to application/json .send(JSON.stringify({ "@context": "http://example.org", test: "ld" })) expect(response.statusCode).toBe(200) - expect(response.body.received["@context"]).toBe("http://example.org") }) it("accepts application/json with charset parameter", async () => { @@ -80,13 +78,22 @@ describe("verifyJsonContentType middleware", () => { expect(response.statusCode).toBe(200) }) + it("accepts application/json with quoted comma in parameter", async () => { + // Exercises the hasMultipleContentTypes quoted-string bypass: a="b,c" contains a comma + // but it is inside quotes, so it should not be treated as a smuggled MIME type. + const response = await request(routeTester) + .post("/json-endpoint") + .set("Content-Type", 'application/json; a="b,c"; xy=z') + .send({ test: "quoted-param" }) + expect(response.statusCode).toBe(200) + }) + it("accepts Content-Type with unusual casing", async () => { const response = await request(routeTester) .post("/json-endpoint") .set("Content-Type", "Application/JSON") .send({ test: "casing" }) expect(response.statusCode).toBe(200) - expect(response.body.received.test).toBe("casing") }) it("returns 415 for missing Content-Type", async () => { @@ -95,7 +102,6 @@ describe("verifyJsonContentType middleware", () => { .unset("Content-Type") .send(Buffer.from('{"test":"data"}')) expect(response.statusCode).toBe(415) - expect(response.text).toContain("Missing or empty Content-Type header") }) it("returns 415 for text/plain", async () => { @@ -104,7 +110,6 @@ describe("verifyJsonContentType middleware", () => { .set("Content-Type", "text/plain") .send("some plain text") expect(response.statusCode).toBe(415) - expect(response.text).toContain("Unsupported Content-Type") }) it("returns 415 for space-separated multiple Content-Type values", async () => { @@ -113,7 +118,6 @@ describe("verifyJsonContentType middleware", () => { .set("Content-Type", "application/json text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") }) it("returns 415 for comma-separated multiple Content-Type values", async () => { @@ -122,7 +126,6 @@ describe("verifyJsonContentType middleware", () => { .set("Content-Type", "application/json, text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") }) it("returns 415 for comma-injected Content-Type parameter", async () => { @@ -133,7 +136,6 @@ describe("verifyJsonContentType middleware", () => { .set("Content-Type", "application/json; charset=utf-8, text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") }) it("returns 415 for semicolon-smuggled MIME type", async () => { @@ -142,7 +144,6 @@ describe("verifyJsonContentType middleware", () => { .set("Content-Type", "application/json; text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") }) it("returns 415 for semicolon-smuggled MIME type with valid parameter", async () => { @@ -151,7 +152,6 @@ describe("verifyJsonContentType middleware", () => { .set("Content-Type", "application/json; charset=utf-8; text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") }) it("returns 415 for space-smuggled MIME type after valid parameter", async () => { @@ -160,7 +160,6 @@ describe("verifyJsonContentType middleware", () => { .set("Content-Type", "application/json; a=b; c=d text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") }) }) @@ -172,7 +171,6 @@ describe("verifyEitherContentType middleware", () => { .set("Content-Type", "application/json") .send({ searchText: "hello" }) expect(response.statusCode).toBe(200) - expect(response.body.received.searchText).toBe("hello") }) it("accepts application/ld+json", async () => { @@ -182,7 +180,6 @@ describe("verifyEitherContentType middleware", () => { // Must stringify manually; supertest's .send(object) would override Content-Type to application/json .send(JSON.stringify({ "@context": "http://example.org" })) expect(response.statusCode).toBe(200) - expect(response.body.received["@context"]).toBe("http://example.org") }) it("accepts text/plain", async () => { @@ -191,7 +188,16 @@ describe("verifyEitherContentType middleware", () => { .set("Content-Type", "text/plain") .send("search terms") expect(response.statusCode).toBe(200) - expect(response.body.received).toBe("search terms") + }) + + it("accepts text/plain with quoted comma in parameter", async () => { + // Exercises the hasMultipleContentTypes quoted-string bypass: a="b,c" contains a comma + // but it is inside quotes, so it should not be treated as a smuggled MIME type. + const response = await request(routeTester) + .post("/json-or-text-endpoint") + .set("Content-Type", 'text/plain; a="b,c"') + .send("search terms") + expect(response.statusCode).toBe(200) }) it("returns 415 for missing Content-Type", async () => { @@ -200,7 +206,6 @@ describe("verifyEitherContentType middleware", () => { .unset("Content-Type") .send(Buffer.from("hello")) expect(response.statusCode).toBe(415) - expect(response.text).toContain("Missing or empty Content-Type header") }) it("returns 415 for application/xml", async () => { @@ -209,7 +214,6 @@ describe("verifyEitherContentType middleware", () => { .set("Content-Type", "application/xml") .send("") expect(response.statusCode).toBe(415) - expect(response.text).toContain("Unsupported Content-Type") }) it("returns 415 for space-separated multiple Content-Type values", async () => { @@ -218,7 +222,6 @@ describe("verifyEitherContentType middleware", () => { .set("Content-Type", "application/json text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") }) it("returns 415 for comma-separated multiple Content-Type values", async () => { @@ -227,7 +230,6 @@ describe("verifyEitherContentType middleware", () => { .set("Content-Type", "application/json, text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") }) it("returns 415 for comma-injected Content-Type parameter", async () => { @@ -238,7 +240,6 @@ describe("verifyEitherContentType middleware", () => { .set("Content-Type", "application/json; charset=utf-8, text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") }) it("returns 415 for semicolon-smuggled MIME type", async () => { @@ -247,7 +248,6 @@ describe("verifyEitherContentType middleware", () => { .set("Content-Type", "application/json; text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") }) it("returns 415 for semicolon-smuggled MIME type with valid parameter", async () => { @@ -256,7 +256,6 @@ describe("verifyEitherContentType middleware", () => { .set("Content-Type", "application/json; charset=utf-8; text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") }) it("returns 415 for space-smuggled MIME type after valid parameter", async () => { @@ -265,6 +264,5 @@ describe("verifyEitherContentType middleware", () => { .set("Content-Type", "application/json; a=b; c=d text/plain") .send('{"test":"data"}') expect(response.statusCode).toBe(415) - expect(response.text).toContain("Multiple Content-Type values are not allowed") }) }) From 69a85f8c3642055af6e0c2f3954ee5521e35fcff Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 25 Mar 2026 12:05:27 -0500 Subject: [PATCH 43/43] changes while testing --- controllers/utils.js | 2 +- rest.js | 1 + routes/__tests__/contentType.test.js | 11 +++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/controllers/utils.js b/controllers/utils.js index 5df982fa..602d372e 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -137,7 +137,7 @@ function getAgentClaim(req, next) { "message": "Could not get agent from req.user. Have you registered with RERUM?", "status": 403 } - next(utils.createExpressError(err)) + return next(utils.createExpressError(err)) } function parseDocumentID(atID){ diff --git a/rest.js b/rest.js index 38cfe71f..abc0d18c 100644 --- a/rest.js +++ b/rest.js @@ -37,6 +37,7 @@ const checkPatchOverrideSupport = function (req, res) { - application/json; a=b; text/plain; - application/json; a=b text/plain; - application/json; charset=utf-8, text/plain + - application/json; * @param {string} contentType - Lowercased Content-Type header value * @returns {boolean} True if multiple MIME types are detected diff --git a/routes/__tests__/contentType.test.js b/routes/__tests__/contentType.test.js index bca925e6..c0989b4f 100644 --- a/routes/__tests__/contentType.test.js +++ b/routes/__tests__/contentType.test.js @@ -13,7 +13,9 @@ - text/plain; application/json - text/plain; a=b, application/json - application/json; a=b; text/plain; + - application/json; a=b text/plain; - application/json; charset=utf-8, text/plain + - application/json; * If a request contains more than one Content-Type header, that should also result in a 415. * @@ -61,6 +63,15 @@ describe("verifyJsonContentType middleware", () => { expect(response.statusCode).toBe(200) }) + it("returns 415 for trailing semicolon without parameter", async () => { + // A trailing semicolon is malformed per RFC 7231 and express.json() won't parse it + const response = await request(routeTester) + .post("/json-endpoint") + .set("Content-Type", "application/json;") + .send('{"test":"trailing-semicolon"}') + expect(response.statusCode).toBe(415) + }) + it("accepts application/json with charset parameter", async () => { const response = await request(routeTester) .post("/json-endpoint")