fix(api): enforce job ownership on cancel endpoint (#599)

The job cancel endpoint authenticated the caller but never checked that the job belonged to them, so any authenticated user could cancel another user's job by ID. Load the job's owner and allow cancellation only for the owner or a caller with files:all; return 404 for missing and non-owned jobs alike. Extract the route into a shared registerJobRoutes() so the ownership check is covered by tests.

Reported by Alpesh Bhagwatkar.
This commit is contained in:
SnapOtter
2026-07-21 15:09:00 +08:00
committed by GitHub
parent a6bce6825a
commit 577d74bdb1
4 changed files with 194 additions and 58 deletions
+3 -18
View File
@@ -67,6 +67,7 @@ import { feedbackRoutes } from "./routes/feedback.js";
import { registerFetchUrlsRoute } from "./routes/fetch-urls.js";
import { filePreviewRoutes } from "./routes/file-preview.js";
import { fileRoutes } from "./routes/files.js";
import { registerJobRoutes } from "./routes/jobs.js";
import { registerMemeTemplates } from "./routes/meme-templates.js";
import { registerPipelineRoutes } from "./routes/pipeline.js";
import { preferencesRoutes } from "./routes/preferences.js";
@@ -706,24 +707,8 @@ app.get("/api/v1/readyz", async (_request, reply) => {
return reply.code(ok ? 200 : 503).send({ ok, postgres, redis, disk: diskOk, s3: s3Ok });
});
// Cancel a job (authenticated)
app.post(
"/api/v1/jobs/:jobId/cancel",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (
request: import("fastify").FastifyRequest<{ Params: { jobId: string } }>,
reply: import("fastify").FastifyReply,
) => {
const { requireAuth } = await import("./plugins/auth.js");
const user = requireAuth(request, reply);
if (!user) return;
const { requestCancel } = await import("./jobs/cancel.js");
const { jobId } = request.params;
const canceled = await requestCancel(jobId);
return reply.send({ canceled });
},
);
// Cancel a job (authenticated; owner or files:all only)
registerJobRoutes(app);
// Serve SPA in production
if (process.env.NODE_ENV === "production") {
+46
View File
@@ -0,0 +1,46 @@
/**
* HTTP control routes for jobs.
*
* POST /api/v1/jobs/:jobId/cancel
*
* Cancellation is authorized at this boundary. Authentication alone is not
* enough: a caller may cancel a job only if they own it (jobs.user_id ===
* their id) or hold `files:all` (admins and editors, the same "act across all
* users" permission that governs the file library). Without this check any
* authenticated user could cancel any other user's job by guessing its id.
*
* Missing and non-owned jobs both return 404 so a caller can't use the response
* to learn which job ids exist. This mirrors the ownership handling in the
* user-files routes.
*/
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../db/index.js";
import { requestCancel } from "../jobs/cancel.js";
import { hasEffectivePermission } from "../permissions.js";
import { requireAuth } from "../plugins/auth.js";
export function registerJobRoutes(app: FastifyInstance): void {
app.post(
"/api/v1/jobs/:jobId/cancel",
{ config: { rateLimit: { max: 300, timeWindow: "1 minute" } } },
async (request: FastifyRequest<{ Params: { jobId: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const { jobId } = request.params;
const [job] = await db
.select({ userId: schema.jobs.userId })
.from(schema.jobs)
.where(eq(schema.jobs.id, jobId));
if (!job || (job.userId !== user.id && !(await hasEffectivePermission(user, "files:all")))) {
return reply.status(404).send({ error: "Job not found" });
}
const canceled = await requestCancel(jobId);
return reply.send({ canceled });
},
);
}