fix(auth): POST /auth/logout 接受任意 Content-Type

该端点不读 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 提到外层。
This commit is contained in:
2026-07-26 22:39:46 +08:00
parent e6e23294a2
commit 50ddf32cc2
2 changed files with 124 additions and 3 deletions
+24 -3
View File
@@ -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) => {
+100
View File
@@ -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<FastifyInstance> {
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();
});
});