forked from EduCraft/curriculum-project-hub
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:
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user