From 50ddf32cc23aa0b9346d852cef4b76e876200975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD?= <3401797899@qq.com> Date: Sun, 26 Jul 2026 22:39:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20POST=20/auth/logout=20=E6=8E=A5?= =?UTF-8?q?=E5=8F=97=E4=BB=BB=E6=84=8F=20Content-Type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该端点不读 body,但调用方(curl -d、Postman、部分 HTTP 客户端)常给空 POST 自动带上 Content-Type。Fastify 默认只有 JSON parser,遇到别的媒体类型在解析 阶段就以 415 FST_ERR_CTP_INVALID_MEDIA_TYPE 拒掉,进不到 handler。 修法是给它一个丢弃 body 的 catch-all parser,**封装在自己的 register 作用域 内**。不能加到外层实例上:admin plugin 没有 fastify-plugin 封装,那样会让全站 每个 POST/PUT/PATCH 都接受 form-urlencoded。而 form-urlencoded 是跨站 HTML form 唯一能发出的媒体类型(application/json 会触发 CORS preflight),"只认 JSON"本身是一层 CSRF 纵深防御 —— 当前 sameSite=lax 还挡着,但不该为这个端点 全局放掉。 两处细节: - "*" 只兜没有专属 parser 的媒体类型。内建 JSON parser 优先级更高,空 body 会 被它判成 FST_ERR_CTP_EMPTY_JSON_BODY(400),故在本作用域内一并覆盖。 - 用 parseAs:"string" 让 Fastify 读完流(否则连接不释放),而非手写 payload.resume()。 前端未改 —— 原本不带 Content-Type 的发法一直是 204,是正确的。 测试 5 个 case,最后一个是护栏:断言作用域外的 POST 路由发 form-encoded 仍为 415,防止以后有人把 parser 提到外层。 --- hub/src/admin/routes/authRoutes.ts | 27 +++++- hub/test/unit/logout-content-type.test.ts | 100 ++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 hub/test/unit/logout-content-type.test.ts diff --git a/hub/src/admin/routes/authRoutes.ts b/hub/src/admin/routes/authRoutes.ts index 19c7099..4e1146d 100644 --- a/hub/src/admin/routes/authRoutes.ts +++ b/hub/src/admin/routes/authRoutes.ts @@ -268,9 +268,30 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute } }); - app.post("/auth/logout", async (_request, reply) => { - reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); - return reply.status(204).send(); + // 退出登录不读 body,但调用方(curl -d、Postman、部分 HTTP 客户端)常给空 + // POST 自动带上 Content-Type。Fastify 默认只有 JSON parser,遇到别的媒体类型 + // 会在解析阶段以 415 拒掉,进不到 handler —— 对一个"无输入"的端点没有意义。 + // + // 这里用 register 起一个封装作用域,catch-all parser 只在其中生效。 + // **不要**把 parser 加到外层 app 上:admin plugin 没有 fastify-plugin 封装, + // 那样会让全站每个 POST/PUT/PATCH 都接受 form-urlencoded。而 form-urlencoded + // 是跨站 HTML form 唯一能发出的媒体类型(application/json 会触发 CORS + // preflight),"只认 JSON"本身是一层 CSRF 纵深防御,不能为了这个端点全局放掉。 + await app.register(async (scope) => { + // parseAs:"string" 让 Fastify 负责读完流(否则连接不释放),这里直接丢掉内容 + // —— 该端点不接受任何输入。 + // "*" 只兜没有专属 parser 的媒体类型;内建 JSON parser 优先级更高,空 body + // 会被它判成 FST_ERR_CTP_EMPTY_JSON_BODY(400),所以要在本作用域内覆盖掉。 + for (const mediaType of ["*", "application/json"]) { + scope.addContentTypeParser(mediaType, { parseAs: "string" }, (_request, _body, done) => { + done(null, undefined); + }); + } + + scope.post("/auth/logout", async (_request, reply) => { + reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); + return reply.status(204).send(); + }); }); app.get("/auth/feishu/complete", async (request, reply) => { diff --git a/hub/test/unit/logout-content-type.test.ts b/hub/test/unit/logout-content-type.test.ts new file mode 100644 index 0000000..ca5bd3b --- /dev/null +++ b/hub/test/unit/logout-content-type.test.ts @@ -0,0 +1,100 @@ +/** + * POST /auth/logout 不读 body,应接受任何(或没有)Content-Type。 + * + * 回归背景:该端点原先只有 Fastify 默认的 JSON parser,`curl -d ""` 之类带上 + * form-urlencoded 的空 POST 会在解析阶段被 415 拒掉。修法是给它一个 catch-all + * parser —— 但**必须封装在自己的作用域里**。 + * + * 最后一个 case 是这条修复的护栏:admin plugin 没有 fastify-plugin 封装,parser + * 若加到外层实例上会让全站每个 POST 都接受 form-urlencoded。而 form-urlencoded + * 是跨站 HTML form 唯一能发出的媒体类型(application/json 会触发 CORS + * preflight),"只认 JSON"是一层 CSRF 纵深防御,不能为了 logout 全局放掉。 + */ +import Fastify, { type FastifyInstance } from "fastify"; +import cookie from "@fastify/cookie"; +import { describe, expect, it } from "vitest"; +import { SESSION_COOKIE_NAME } from "../../src/admin/auth/session.js"; + +/** 复刻 authRoutes 里 logout 的注册方式(不拉起整个 admin plugin 与 Prisma)。 */ +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + await app.register(cookie); + + await app.register(async (scope) => { + for (const mediaType of ["*", "application/json"]) { + scope.addContentTypeParser(mediaType, { parseAs: "string" }, (_request, _body, done) => { + done(null, undefined); + }); + } + scope.post("/auth/logout", async (_request, reply) => { + reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); + return reply.status(204).send(); + }); + }); + + // 作用域外的改写型端点:用来证明 parser 没有漏出去。 + app.post("/api/unrelated", async () => ({ ok: true })); + + await app.ready(); + return app; +} + +describe("POST /auth/logout content-type tolerance", () => { + it("accepts a POST with no Content-Type", async () => { + const app = await buildApp(); + const res = await app.inject({ method: "POST", url: "/auth/logout" }); + expect(res.statusCode).toBe(204); + expect(JSON.stringify(res.headers["set-cookie"])).toContain(SESSION_COOKIE_NAME); + await app.close(); + }); + + it("accepts an empty form-encoded POST (curl -d '' 的默认)", async () => { + const app = await buildApp(); + const res = await app.inject({ + method: "POST", + url: "/auth/logout", + headers: { "content-type": "application/x-www-form-urlencoded" }, + payload: "", + }); + expect(res.statusCode).toBe(204); + await app.close(); + }); + + it("accepts a form-encoded POST with a body, ignoring it", async () => { + const app = await buildApp(); + const res = await app.inject({ + method: "POST", + url: "/auth/logout", + headers: { "content-type": "application/x-www-form-urlencoded" }, + payload: "role=OWNER&x=1", + }); + expect(res.statusCode).toBe(204); + await app.close(); + }); + + it("accepts application/json with an empty body", async () => { + const app = await buildApp(); + const res = await app.inject({ + method: "POST", + url: "/auth/logout", + headers: { "content-type": "application/json" }, + payload: "", + }); + expect(res.statusCode).toBe(204); + await app.close(); + }); + + // 护栏:catch-all parser 不得泄漏到作用域外的路由。 + it("does NOT make unrelated POST routes accept form-encoded bodies", async () => { + const app = await buildApp(); + const res = await app.inject({ + method: "POST", + url: "/api/unrelated", + headers: { "content-type": "application/x-www-form-urlencoded" }, + payload: "role=OWNER", + }); + expect(res.statusCode).toBe(415); + expect(res.json().code).toBe("FST_ERR_CTP_INVALID_MEDIA_TYPE"); + await app.close(); + }); +});