Skip to content

Unguessable slugs; accept slug or PK on detail API#623

Open
skaphan wants to merge 1 commit intoartoonie:mainfrom
skaphan:unguessable-slugs
Open

Unguessable slugs; accept slug or PK on detail API#623
skaphan wants to merge 1 commit intoartoonie:mainfrom
skaphan:unguessable-slugs

Conversation

@skaphan
Copy link
Copy Markdown
Contributor

@skaphan skaphan commented Apr 19, 2026

Summary

  • _get_unique_slug() now appends 12 random hex chars (secrets.token_hex(6)) instead of an incrementing -N counter. Slugs stay title-prefixed (good for SEO and admin browsing) but become non-enumerable.
  • JsonOnlyViewSet, VerboseViewSet, and BallotpediaViewSet accept either the integer PK or the slug as the detail URL's lookup value, via a small PkOrSlugLookupMixin. No URL routing changes.

Motivation

  1. Slug enumeration. Anyone who can guess or discover a title can walk the counter space (/v/<title>, /v/<title>-1, /v/<title>-2, ...) to browse other visualizations the instance has stored. Random suffixes close this off.
  2. Cross-client PATCH overwrite on ephemeral deployments. Integer PKs are reused after a database reset (e.g. Cloud Run with an ephemeral DB). A stale PATCH from one client can silently overwrite a different client's freshly-created record that happened to land on the same PK. Addressing records by slug (the new unguessable form) eliminates the collision vector because 48 bits of randomness cannot recur after a reset.

Backward compatibility

  • No schema change, no data migration. Old records keep their counter-style slugs and remain valid. Only newly-created records get the random suffix.
  • Existing integer-PK clients keep working. PkOrSlugLookupMixin.get_object() dispatches digit-only lookup values as PKs (falling back to slug if not found), and non-digit values as slugs. Clients can opt into slug-based addressing at their own pace.
  • The existing uniqueness loop is retained as belt-and-suspenders for the astronomically-rare 48-bit collision.

Tests

  • Updated existing tests that asserted the old -N format to assert the new -<12-hex> format (regex).
  • New test_detail_accepts_pk_and_slug exercises the dual-lookup mixin: both /api/visualizations/<pk>/ and /api/visualizations/<slug>/ resolve to the same record.

Test plan

  • ./scripts/run-tests.sh passes
  • Upload a new visualization; confirm URL uses the random-suffix slug
  • curl /api/visualizations/<pk>/ and curl /api/visualizations/<slug>/ both return the same record
  • Existing records with counter-style slugs remain reachable

The slug generator previously appended a counter (-1, -2, ...) to
make slugs unique across records that shared a title. Two problems
with that:

1. Slugs are enumerable. Anyone who can guess a title can walk the
   counter space and browse other visualizations the system has
   created, even those not listed publicly.
2. Counter values are reused across database resets. On an ephemeral
   or externally-managed instance, a stale client PATCHing by PK can
   silently overwrite an unrelated record that happened to land on
   the same integer after a reset.

_get_unique_slug now appends 12 random hex chars (secrets.token_hex(6))
instead of an incrementing counter. The title prefix is preserved for
SEO and admin browsing; the random suffix makes slugs non-enumerable
and unique across resets. The existing uniqueness loop is retained as
a belt-and-suspenders guard for the astronomically-rare 48-bit
collision. No schema change, no data migration: old records keep
their counter-style slugs and remain valid.

To let clients address records by the new unguessable slug instead of
the reusable PK, the three write-capable ModelViewSets now use a new
PkOrSlugLookupMixin. Its get_object() treats digit-only lookup values
as PKs (backward compat for existing clients), falling back to slug
lookup if the PK is not found; non-digit values dispatch to slug. No
URL-routing changes.

Tests updated: slug assertions regex-match the new format. Added a
new test for dual PK/slug lookup on the detail endpoint.

Co-Authored-By: Claude Opus 4.7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant