fix: weekly defect review — null crashes in slug ops, LIKE wildcard escape, TOCTOU 409#12
Conversation
…nk race disableSlug dereferences disabled!.disabled_at (line 279) without checking for null first. SlugRepository.disable() returns Slug | null; if a concurrent DELETE removes the slug row between the service's existence check and the UPDATE, the function throws TypeError instead of returning 404. Same pattern applies to enableSlug, which returned ok(null) when enabled was null. addCustomSlugToLink checked SlugRepository.exists() before calling addCustom() but did not wrap the INSERT in a try/catch. Two concurrent requests can both pass the exists() check and race to the INSERT; the second hits the UNIQUE constraint and the D1 error propagates as an unhandled 500. Fix disableSlug/enableSlug by adding null guards that return fail(404) before any property access. Fix addCustomSlugToLink by catching UNIQUE constraint errors from addCustom() and translating them to fail(409). Regression tests: ownership.test.ts now covers all three paths by mocking the repository to simulate the race condition. https://claude.ai/code/session_01GJCYffCLtJSQXXBTuZJJSN
The search query was embedded directly into a SQL LIKE pattern as '%' + query + '%' without escaping the SQLite wildcards % and _. A search for '_' creates the pattern '%_%', which matches any string with at least one character, so every link with a non-null label, slug, or URL would be returned. Similarly, a search for '%' would match every string. Neither is the intended behavior. Fix: escape \ first, then % as \%, then _ as \_, and add ESCAPE '\' to every LIKE comparison. Regression tests confirm that searching for '_' and '%' returns zero results when no links contain those literal characters. https://claude.ai/code/session_01GJCYffCLtJSQXXBTuZJJSN
|
FLAG: Non-atomic delete leaves zombie links on Worker crash
// link-repository.ts lines 157-159
await db.prepare("DELETE FROM clicks WHERE slug IN (SELECT slug FROM slugs WHERE link_id = ?)").bind(id).run();
await db.prepare("DELETE FROM slugs WHERE link_id = ?").bind(id).run();
await db.prepare("DELETE FROM links WHERE id = ?").bind(id).run();If the Worker is killed (CPU/memory eviction, deploy rollover) between any two steps, the DB is left in a partially-cleaned state:
Recommended fix: Wrap the three DELETEs in Generated by Claude Code |
|
FLAG: HTTP 301 permanent redirect makes link disabling ineffective for cached clients
return Response.redirect(entry.url, 301);HTTP 301 is a permanent redirect. Browsers cache it with no expiry and no revalidation. Once a browser follows a short link, it stores the destination locally. Any subsequent This means:
For a URL shortener this is a significant operational gap: an admin who disables a link pointing to stale or harmful content cannot guarantee it stops working. Recommended fix: Change to Generated by Claude Code |
|
FLAG:
If a link was created with a future // disable: link-repository.ts line 129
await db.prepare("UPDATE links SET expires_at = ? WHERE id = ?").bind(now, id).run();
// enable: link-repository.ts line 136
await db.prepare("UPDATE links SET expires_at = NULL WHERE id = ?").bind(id).run();The Recommended fix: Add a Generated by Claude Code |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
shrtnr | 8be21bb | Jun 11 2026, 09:20 AM |
|
FLAG:
linksApp.openapi(listLinksRoute, async (c) => {
const { owner, range } = c.req.valid("query") as { owner?: string; range?: TimelineRange };
const result = owner
? await listLinksByOwner(c.env, owner, opts)
: await listLinks(c.env, opts);
return fromServiceResult(result) as never;
});The middleware for this route is Decision needed: Is if (owner && owner !== c.var.auth.identity) return fail(403, "Cannot list links for another owner");If admin visibility is intentional, no change needed — but this should be documented as a design decision. Generated by Claude Code |
There was a problem hiding this comment.
Pull request overview
Fixes three production-facing defects in slug/link operations by hardening service/repository behavior against race conditions and ensuring SQLite LIKE searches treat user input literally.
Changes:
- Add null guards in
disableSlug/enableSlugto prevent crashes and return 404 on concurrent slug deletion. - Translate concurrent unique constraint failures in
addCustomSlugToLinkinto a 409 conflict response. - Escape SQLite
LIKEmetacharacters (%,_,\) and addESCAPE '\'so search queries don’t behave like wildcard patterns.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| src/services/link-management.ts | Adds race-safe null handling for slug enable/disable and converts UNIQUE insert races into 409s. |
| src/db/link-repository.ts | Escapes LIKE metacharacters and applies ESCAPE '\' across search predicates. |
| src/tests/service/ownership.test.ts | Adds regression tests covering the three fixed defect scenarios (404/409 behavior, literal search). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
A bare 301 is cached by browsers indefinitely, so a returning visitor never contacts the Worker again: disabling, expiring, or retargeting a link had no effect for them, and their repeat clicks went unrecorded. Keep the 301 for its SEO semantics but add Cache-Control "private, max-age=90" (the Bitly approach) so browsers revalidate within seconds. Location serialization is unchanged.
|
Closing out the four flagged items with decisions from review:
|
Weekly defect-hunting review. Three confirmed bugs, each with a regression test. All 901 tests pass.
Fix 1:
disableSlug/enableSlug— TypeError crash on concurrent deleteWhat was wrong:
disableSlugaccesseddisabled!.disabled_at(line 279) without a null guard.SlugRepository.disable()returnsSlug | null; if the slug row is concurrently deleted between the service'slink.slugs.find()check and theUPDATE, the function returnsnulland the!assertion throwsTypeError: Cannot read properties of null (reading 'disabled_at').enableSlughad the same pattern —ok(enabled!)with a null return produces{ ok: true, data: null }instead of a 404.Impact: Any concurrent
DELETE /links/:idthat races aPATCH /slugs/:slug/disableor/enableproduces an unhandled 500 instead of a graceful 404.Fix: Added
if (!disabled) return fail(404, "Slug not found")afterSlugRepository.disable(), and the same guard afterSlugRepository.enable(). Removed the!non-null assertions. Follows the same pattern already in place fordisableLink(added in the prior PR).Test:
ownership.test.ts— mocksSlugRepository.disable/enableto returnnulland asserts status 404 rather than a throw.Fix 2:
addCustomSlugToLink— TOCTOU produces unhandled 500 on concurrent insertWhat was wrong: The duplicate-slug check was a separate
SlugRepository.exists()call beforeSlugRepository.addCustom(). Two concurrent requests can both pass theexists()check before either commits. The secondINSERThits theUNIQUEconstraint onslugs.slug.addCustom()has no try/catch;addCustomSlugToLinkhas no try/catch. The D1 error propagates as an unhandled 500 instead of a 409.Impact: Concurrent requests to add the same custom slug produce a 500 instead of the expected conflict response.
Fix: Wrapped
SlugRepository.addCustom()in a try/catch. Errors whose message contains"UNIQUE constraint failed"are translated tofail(409, "Slug already exists").Test:
ownership.test.ts— mocksSlugRepository.addCustomto throw a UNIQUE constraint error and asserts status 409.Fix 3:
LinkRepository.search— LIKE metacharacter wildcardWhat was wrong: The search pattern was built as
`%${query.trim().toLowerCase()}%`with no escaping. SQLite LIKE treats%as "any sequence" and_as "any single character". Searching for_creates the pattern%_%, which matches any string with at least one character — effectively returning every link with a non-null label, slug, or URL.Impact: A search for
_returns every link in the database. A search for50%orexample_commatches rows that do not literally contain those strings. Incorrect results with no error.Fix: Escape
\→\\,%→\%,_→\_before building the pattern, and addESCAPE '\'to everyLIKEcomparison in theWHEREclause.Test:
ownership.test.ts— creates links with no underscores or percent signs, then asserts that searching for_and%returns zero results.Items flagged for developer judgment (PR comments)
See inline comments on this PR for four issues that need design or schema decisions before fixing.
Generated by Claude Code