Skip to content

Commit bb9e68f

Browse files
committed
fix(worker): three correctness fixes found in review
- Validate drinkId before getReviewSummary to prevent an injection path through the resource-name segments (INVALID_RESOURCE_NAME 400) - Reject unknown updateMask fields rather than silently ignoring them (UNKNOWN_FIELD_MASK 400), matching AIP-134 contract guarantees - Eliminate post-write readRow() in upsert: compute finalStarRating / finalRecommend before writing and build the response from those values, removing one DB round trip and closing a TOCTOU race where a concurrent DELETE between write and re-read caused a non-null assertion crash Test: adds UNKNOWN_FIELD_MASK case; all 86 tests pass https://claude.ai/code/session_01VVTCRjdHqcTJCVEjEVQV6C
1 parent 0f8a0e8 commit bb9e68f

2 files changed

Lines changed: 51 additions & 19 deletions

File tree

cloudflare-worker/reviews.ts

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,17 @@ export async function handleReviews(
217217

218218
// isSummary
219219
if (segments.length === 4) {
220-
return getReviewSummary({ db, bucket, festivalId, drinkId: segments[3], corsHeaders });
220+
const drinkId = segments[3];
221+
if (!isValidId(drinkId)) {
222+
return errorResponse(
223+
400,
224+
"INVALID_ARGUMENT",
225+
"Invalid resource name",
226+
"INVALID_RESOURCE_NAME",
227+
corsHeaders,
228+
);
229+
}
230+
return getReviewSummary({ db, bucket, festivalId, drinkId, corsHeaders });
221231
}
222232
return listReviewSummaries({ db, bucket, festivalId, url, corsHeaders });
223233
}
@@ -311,12 +321,24 @@ async function upsertReview(request: Request, ctx: ReviewCtx): Promise<Response>
311321
}
312322

313323
// Parse updateMask: comma-separated field names. Absent/empty = all provided fields.
324+
const KNOWN_FIELDS = new Set(["starRating", "wouldRecommend"]);
314325
const patch = body as Record<string, unknown>;
315326
const maskRaw = patch.updateMask;
316-
const mask: Set<string> | null =
317-
typeof maskRaw === "string" && maskRaw.length > 0
318-
? new Set(maskRaw.split(",").map((s) => s.trim()))
319-
: null;
327+
let mask: Set<string> | null = null;
328+
if (typeof maskRaw === "string" && maskRaw.length > 0) {
329+
const fields = maskRaw.split(",").map((s) => s.trim());
330+
const unknown = fields.filter((f) => !KNOWN_FIELDS.has(f));
331+
if (unknown.length > 0) {
332+
return errorResponse(
333+
400,
334+
"INVALID_ARGUMENT",
335+
`Unknown updateMask field(s): ${unknown.join(", ")}`,
336+
"UNKNOWN_FIELD_MASK",
337+
corsHeaders,
338+
);
339+
}
340+
mask = new Set(fields);
341+
}
320342

321343
const updateStar = mask === null ? "starRating" in patch : mask.has("starRating");
322344
const updateRec = mask === null ? "wouldRecommend" in patch : mask.has("wouldRecommend");
@@ -364,37 +386,36 @@ async function upsertReview(request: Request, ctx: ReviewCtx): Promise<Response>
364386
const existing = await readRow(db, bucket, festivalId, drinkId, deviceId);
365387
const now = Date.now();
366388

389+
// Compute the final column values upfront so we can build the response
390+
// without a second DB read — avoids a round trip and the race where a
391+
// concurrent DELETE between write and re-read would make row! throw.
392+
const finalStarRating = updateStar ? (starRating ?? null) : (existing?.star_rating ?? null);
393+
const finalRecommend = updateRec ? (recommend ?? null) : (existing?.recommend ?? null);
394+
367395
if (existing) {
368396
await db
369397
.prepare(
370398
"UPDATE reviews SET star_rating = ?, recommend = ?, updated_at = ? " +
371399
"WHERE bucket = ? AND festival_id = ? AND drink_id = ? AND device_id = ?",
372400
)
373-
.bind(
374-
updateStar ? starRating : existing.star_rating,
375-
updateRec ? recommend : existing.recommend,
376-
now,
377-
bucket, festivalId, drinkId, deviceId,
378-
)
401+
.bind(finalStarRating, finalRecommend, now, bucket, festivalId, drinkId, deviceId)
379402
.run();
380403
} else {
381404
await db
382405
.prepare(
383406
"INSERT INTO reviews (bucket, festival_id, drink_id, device_id, star_rating, recommend, updated_at) " +
384407
"VALUES (?, ?, ?, ?, ?, ?, ?)",
385408
)
386-
.bind(
387-
bucket, festivalId, drinkId, deviceId,
388-
updateStar ? (starRating ?? null) : null,
389-
updateRec ? (recommend ?? null) : null,
390-
now,
391-
)
409+
.bind(bucket, festivalId, drinkId, deviceId, finalStarRating, finalRecommend, now)
392410
.run();
393411
}
394412

395-
const row = await readRow(db, bucket, festivalId, drinkId, deviceId);
396413
return jsonResponse<Review>(
397-
serializeReview(reviewName(festivalId, drinkId), row!),
414+
serializeReview(reviewName(festivalId, drinkId), {
415+
star_rating: finalStarRating,
416+
recommend: finalRecommend,
417+
updated_at: now,
418+
}),
398419
200,
399420
corsHeaders,
400421
);

cloudflare-worker/test/reviews.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,17 @@ describe("reviews — PATCH (upsert)", () => {
134134
expect(data.averageRating).toBe(5);
135135
});
136136

137+
it("rejects an unknown updateMask field with a structured error", async () => {
138+
const response = await patch("cbf2025", "beer-1", {
139+
starRating: 3,
140+
updateMask: "starRating,bogusField",
141+
});
142+
expect(response.status).toBe(400);
143+
const { error } = await response.json();
144+
expect(error.status).toBe("INVALID_ARGUMENT");
145+
expect(error.details[0].reason).toBe("UNKNOWN_FIELD_MASK");
146+
});
147+
137148
it("rejects an out-of-range starRating with a structured error", async () => {
138149
const response = await patch("cbf2025", "beer-1", { starRating: 9 });
139150
expect(response.status).toBe(400);

0 commit comments

Comments
 (0)