sakibkx
Start a project

A bilingual site, and a security pass on a live system

An association connecting Chinese and Bangladeshi universities needed its whole site published in two languages and edited by staff with no developer. It was already live when I audited it and found three write endpoints with no authentication at all.

RoleBuilt it, then audited and hardened it.
Authored by me88.7% of surviving lines
Year2026
StatusLive
Visit cbecea.com
Measured
Findings closed68severity-graded register in design/ISSUES.md with per-file line references, closed in code
Write routes guarded18 of 18grep across every API route file with a write handler, cross-checked against auth calls
HTTP handlers45exported GET/POST/PATCH/DELETE across 22 route files, plus 24 server actions
Translation sync174 / 174recursive leaf-key walk over both locale catalogues, key sets identical in both directions

How authorship was measured. 31,666 of 35,711 lines, measured with per-file git blame across all tracked text files, excluding lockfiles and binaries.

The problem

A non-profit association connecting Chinese and Bangladeshi universities needed its entire website published in English and Chinese, edited by staff with no developer involved. Programmes, services, events, news, leadership, membership tiers.

It was already live when I audited it. Three write endpoints had no authentication at all. One of them let anyone overwrite the association’s public contact email and address. That is a ready-made phishing primitive: change the address, wait for the enquiries.

What I did

I wrote a severity-graded register of 68 findings with per-file line references, then closed them.

The authentication callback was granting admin to every row in the users collection, which made the entire role check decorative. The middleware was returning next() for every admin path. The image configuration allowed any hostname, which is an open image proxy anyone can launder traffic through. A member could approve their own membership application. Unpublished drafts leaked. A failed file read returned the server’s filesystem layout.

All of that is now closed, and the register is a document a client can read.

Rate limiting became two layers. There is a signed state cookie with four rule tiers. Behind it there is a separate in-process counter, checked first, because a cookie-only limiter is reset by discarding the cookie, which is exactly why the original login brute-force cap never fired. The process manager is pinned to a single instance because that counter lives in process memory. That is a real constraint and I would rather state it than hide it.

The upload path rejects on content length before it buffers anything, then re-checks the bytes actually read, because a client-supplied length cannot be trusted. The underlying library’s own size limit had been sitting at its 200 MB default while the handler believed it was enforcing 10 MB. Everything accepted is transcoded to WebP under a random filename, which is what actually closes the stored-XSS vector rather than filtering extensions.

Bilingual content is stored as pairs. Every item exists as an English row and a Chinese row sharing a slug, written together in one operation. The update path refuses to run if the pair count is not exactly two. Bilingual sites almost always rot into English-only rows with orphaned translations. This makes that structurally hard.

Why this is the one I show first to a nervous client

Anyone can show you a feature they built. Very few people can show you a written before-and-after security register on a production system, with the commits that closed it.

Stack
  • Next.js 16
  • TypeScript
  • MongoDB
  • Auth.js
  • sharp
  • nginx
  • PM2
Scope of the claim

What this project does not prove

  • The contact form on the live site is still a mailto link and stores nothing. The membership form collects documents and a signature and stores neither. Both are on my list, not done.
  • This project has no automated tests.
  • The rate limiter is deliberately pinned to a single process. It does not scale horizontally and I explain why below.