Dev.to Security 🔐 Cybersecurity 👁 0 📖 10 min read

A Facebook Reel Made Me Audit My Job Board. It Found a $29 Product for One Cent.

GiggleGigs is a job board I run: Django + DRF on the backend, React + Vite on the front (on Vercel), Celery for background work, and PayPal for payments, all on a single 2 GB DigitalOcean droplet. Companies post gigs, se

GiggleGigs is a job board I run: Django + DRF on the backend, React + Vite on the front (on Vercel), Celery for background work, and PayPal for payments, all on a single 2 GB DigitalOcean droplet. Companies post gigs, seekers apply, and companies pay to unlock contact details and post more jobs.

In the last post I got deploys down to zero downtime. This one is about what those deploys were shipping.

Why I looked

I watched a Facebook reel about checking RLS (row-level security) on websites. RLS is a database feature that decides which rows each user can read or change. It's mostly a Supabase thing, where the browser talks to the database directly, so a missing policy means anyone can read the table.

GiggleGigs doesn't use RLS. Django sits between the browser and Postgres, and the API code decides who sees what. But that only moves the question, it doesn't answer it. So I went through every endpoint and asked:

Who is allowed to see or do this, and does the code actually check?

Then I had an AI code review go over my fixes. It found the worst bug of the lot, which my own pass had missed.

The worst one: a $29 job post for one cent

A single job post costs $29. The checkout was two calls:

  1. The server creates a $29 PayPal order.
  2. The browser approves it and sends the order ID back to be captured.

Here's the capture, simplified:

def post(self, request, job_id):
    job = get_object_or_404(Job, pk=job_id, posted_by=request.user)
    capture = paypal.capture_order(request.data["order_id"])
    if capture["status"] == "COMPLETED":
        Subscription.objects.create(job=job, amount_paid=plan.price, ...)
        job.activate()

It never looks at the order. And the order doesn't have to come from my server: the PayPal client ID is public, because the checkout button needs it. Anyone can create an order in their own browser for any amount, approve it, and send its ID to my capture endpoint.

Pay $0.01, get a $29 listing. The database would even record it as $29 paid.

First fix: not enough

My first fix fetched the order before capturing and checked it was for the right job. The review pointed out that the amount was still whatever the attacker chose. The fix that shipped checks everything the server would have set itself:

def _single_post_order_error(purchase_unit, job, plan, company):
    if purchase_unit.get("reference_id") != str(job.id):
        return "This order is not for this job."

    expected = plan.price
    if promo_code := purchase_unit.get("custom_id"):
        try:
            expected = validate_promo_code(promo_code, company, plan.price).final_amount
        except PromoCodeError as exc:
            return str(exc)

    amount = purchase_unit.get("amount") or {}
    try:
        value = Decimal(str(amount.get("value")))
    except InvalidOperation:
        value = None
    if amount.get("currency_code") != "USD" or value != Decimal(expected).quantize(Decimal("0.01")):
        return "This order's amount doesn't match the price."
    return None

It runs before capture_order, so a bad order is rejected and nobody is charged for it. amount_paid now comes from what PayPal actually charged, not the plan price.

Subscriptions anyone could claim

Subscriptions have the same shape. After checkout, the browser sends a subscription ID and a plan, and the server activates the plan. The server only checked that the subscription was ACTIVE, so a company could:

  • pay for Starter ($49) and ask to activate Growth ($99)
  • submit someone else's subscription ID and take its access

The plan check is one line: the plan_id PayPal reports must match the plan being activated. Ownership needed more, because PayPal doesn't know who my users are. So every subscription now carries a custom_id with the company's ID, and confirm rejects one that names another company.

There's a catch. The main checkout creates the subscription through PayPal's JS button, which means the browser sets custom_id. That's fine for the owner part, since claiming to be yourself gets you nothing. But I also wanted it to carry the promo code behind a discount, and the browser could just invent one.

So the promo part is signed with Django's Signer:

_custom_id_signer = signing.Signer(salt="job_board.billing.subscription_custom_id")

def make_subscription_custom_id(company_id, promo_code=""):
    if not promo_code:
        return str(company_id)
    return _custom_id_signer.sign(f"{company_id}:{promo_code}")

def read_subscription_custom_id(custom_id):
    """Returns (owner_id, promo_code). An unsigned promo part is ignored."""
    owner_id = custom_id.partition(":")[0]
    try:
        return owner_id, _custom_id_signer.unsign(custom_id).partition(":")[2]
    except signing.BadSignature:
        return owner_id, ""

PayPal's custom_id allows 127 characters, and a signed one is about 60.

Old subscriptions have no custom_id at all. Rejecting them on deploy day would have charged anyone mid-checkout and then refused them, so there's a cutoff instead: subscriptions created from 20 November 2026 must have one.

Resumes anyone could download

A resume has the name, email and phone number that my paywall hides. Resumes were stored in S3 next to logos and profile pictures, under media/, and the bucket policy makes media/ public. So anyone holding a link could download one.

The obvious fix is signed URLs, which expire. I added those first, and the review pointed out they didn't help: remove the signature from the link and the file still downloads, because the object is still under the public prefix.

So resumes moved to their own prefix, which the bucket policy never made public:

class PrivateMediaS3Storage(MediaS3Storage):
    location = "private"
    object_parameters = {"CacheControl": "private, max-age=3600"}
    querystring_auth = True
    querystring_expire = 60 * 60
    signature_version = "s3v4"
    # S3Storage skips signing entirely when a custom domain is set.
    custom_domain = None
resume = models.FileField(upload_to="seeker/resumes/", storage=private_media_storage, ...)

The bucket policy didn't change at all. It already allowed public reads on arn:aws:s3:::your-bucket/media/* only, so anything outside media/ is private by default. What did need changing was the app's IAM policy, which only allowed writes to media/*. Without adding private/*, every resume upload would have failed after the deploy.

Two things I nearly missed:

  • signature_version = "s3v4" needs the bucket's real region. AWS_S3_REGION_NAME defaulted to None in production. One curl -sI https://your-bucket.s3.amazonaws.com | grep x-amz-bucket-region told me it was us-east-1.
  • custom_domain = None is required. django-storages silently skips signing when a custom domain is set.

Moving the existing files

A management command copies each resume from media/ to private/, and with --delete-originals, deletes the public copy after its private copy exists:

for obj in bucket.objects.filter(Prefix=f"media/{path}"):
    new_key = f"private/{obj.key.removeprefix('media/')}"
    bucket.Object(new_key).copy_from(
        CopySource={"Bucket": bucket.name, "Key": obj.key},
        MetadataDirective="REPLACE",
        ContentType=obj.Object().content_type,
        CacheControl="private, max-age=3600",
    )
    if options["delete_originals"]:
        obj.delete()

S3 copies don't carry over ACLs, and copy_from raises on failure, so an original is never deleted without its copy. That mattered: my SSH session dropped halfway through the delete run, and running it again was safe.

In production:

What Count
Resume files copied 392
Seeker profiles with a resume 168
Old versions nobody linked to anymore, but still public 224
Left under media/ afterwards 0

The 224 surprised me. Replacing a resume never deleted the old file, so every version anyone had uploaded was still public.

Then the check from outside:

Request Result
Signed private/ link 206 application/pdf
Same file, signature removed 403
Old media/ link 403

Telling registered emails apart

Three forms told you whether an email had an account, which is exactly what a phisher wants to know:

Form Before After
Log in "No account found with this email address" The same error as a wrong password
Sign up "A user with this email already exists" "Check your email to verify"
Invite a colleague "A user with this email is already registered" "Invitation sent"

If you sign up with a taken email, the owner gets an email saying someone tried, at most once an hour, and the response looks like a normal signup.

Making the responses look the same was easy. Making them be the same took three more rounds, all found in review:

  1. The resend token. Sign up returns a token for the "resend verification email" button. For a taken email I returned a fake one, but Django's signing.dumps signs, it doesn't encrypt. Base64-decode the first part and you get {"uid": null} for a taken email and {"uid": 123} for a real one. Both are now random strings looked up in Redis:
   def _store_resend_token(uid):
       token = secrets.token_urlsafe(32)
       cache.set(f"users:resend_token:{token}", {"uid": uid}, RESEND_VERIFICATION_MAX_AGE)
       return token
  1. The resend reply. Right after a real signup, allauth's cooldown makes resend answer "wait a few minutes". The fake token answered "sent". It now follows the same 3-minute cooldown.
  2. Timing. A real signup hashes a password, and a taken email skipped that, so it answered faster. Both paths now pay the same hashing cost, and so does a login with an unknown email (make_password on a throwaway value).

The smaller stuff

Most of the rest were responses that returned more than the viewer should see:

Leak Was visible to Now limited to
A company's login email, inside every job listing Anyone The company and staff
Draft jobs, by guessing the URL Anyone Their owner and staff
How many people applied to a job Anyone signed in The company that posted it, and staff
The AI's match score and summary of an application The applicant The company and staff
The full seeker list Anyone Growth plan companies
Deactivated accounts Anyone Nobody
The OpenAPI schema Anyone Staff
Messaging a candidate about someone else's job Any company The company's own jobs
Applying to or liking a closed job Any seeker Active jobs only

And a hierarchy bug: an employee moderator could suspend a superuser, and any staff member could edit other staff or grant staff access. Now only a superuser can.

Then I leaked my own keys

One audit item was that .envs/.local/.django was tracked in git. cookiecutter-django tracks .envs/.local/ by default, on the assumption it only holds throwaway values. Mine had real ones: PayPal, Gemini, Turnstile and Google sign-in.

The AI assistant ran the fix, and the fix pushed the newest keys to GitHub:

git rm --cached .envs/.local/.django
git commit -- .envs/.local/.django   # pathspec: takes the file from disk

git rm --cached removes the file from the index. But git commit -- <path> commits that path's contents from the working tree, which put it straight back in. The commit and the push were one command, so nothing looked at it in between.

The repo is private with no forks. The cleanup, in the order that matters:

  1. Rotate. New PayPal secret, Gemini key and Turnstile secret, old ones revoked. Rewriting history doesn't un-leak anything: anyone who fetched the branch already has the values.
  2. Rewrite. I rebuilt the commit without the file in a separate git worktree, because checking out an older commit in place would have overwritten my newly rotated, gitignored env file. Then I force-pushed with --force-with-lease=dev:<old sha>, so it would refuse if anyone else had pushed.
  3. Prevent. The env file is gitignored, there's a .django.example in its place, and CI copies the example before it starts the stack.

The deploy

There's no staging backend, and CI only runs on pushes to main, so this went straight to production after 464 backend tests and a full pre-commit run.

Two production surprises:

  • docker compose exec skips the entrypoint. cookiecutter-django's /entrypoint is what builds DATABASE_URL from the Postgres env vars. Without it, manage.py shell fell back to the SQLite default in base.py and failed with no such table: seekers_seekerprofile. The S3-only move command worked fine, which is why I didn't notice until I tried to count things. Any database command needs exec django /entrypoint python manage.py ....
  • Nobody had exploited anything, because nobody had paid yet. I compared every subscription and single-post order against PayPal: 0 of each.

Takeaways

  • Anything the browser sends is a claim, not a fact. The order ID, the plan, the subscription ID and custom_id all needed checking against PayPal or the database.
  • A public client ID means anyone can create orders. Check the amount, currency and item at capture, before charging.
  • Signed URLs only protect private objects. If the prefix is public, stripping the signature still works.
  • signing.dumps is readable. If a token's payload says something, assume an attacker can read it.
  • Review the fix, not just the bug. Three of my first fixes were incomplete, and the second review pass caught all of them.
  • Click through as each kind of user. Tests passed, but a hard reload of the seeker page fetched before the session was restored and hid a seeker's own resume from them.
  • After untracking a file, look at what's staged before you push. git commit -- <path> reads the working tree.

GiggleGigs is a job board for gig and contract work: gigglegigs.com.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.