mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
+3
-18
@@ -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") {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user