A maintenance release. No new features: this is a security pass over the API and the admin
console, a correctness fix to domain blocking, and the repository groundwork (tests,
contributing guide, security policy, this changelog) that was missing.
Upgrading from 3.0.0: no database migration. Two things to check —
- If you deploy behind Vercel or Cloudflare, set
TRUSTED_IP_HEADER(see
server/README.md). Client IPs now come fromreq.ip
rather than from whichever proxy header happened to be present, so without it
lastLoginIpand the login audit entries will record your web app's egress address
instead of the operator's. GET /healthno longer returnsenvironment,uptimeSeconds, or the database error
string. If a monitor parses those fields, point it atstatusand the 200/503 split.
Changed
- Blocking a domain now takes down the links that already point at it. Previously the
blocklist was only consulted when a new link was created, so mass-minting links to a
phishing domain and waiting for the first report meant every existing link kept
redirecting after the moderator blocked it — the console displayed the domain as blocked
whileshort.msyb.devcarried on serving it. Blocking now blacklists and flags every
matching link in one step, and reports how many were taken offline
(src/modules/admin/moderation.controller.ts). - One blocklist entry covers the apex,
www.and every subdomain, automatically.
A leadingwww.is stripped before the entry is stored, so blockingwww.evil.comno
longer coversa.www.evil.comwhile leavingevil.comitself reachable. Blocking
evil.commatchesevil.com,www.evil.comandanything.evil.com, at creation time
and retroactively, while leavingnotevil.comalone. Blocking a specific subdomain
(sub.evil.com) still scopes to that subdomain. - The admin console reports the result of a block as "N existing links taken offline"
rather than a bare confirmation (frontend/src/components/admin/DomainsManager.tsx).
Security
- Closed an admin account-enumeration oracle in the login endpoint. The hash used to
equalise timing for an unknown email was a hand-written literal of 65 characters; bcrypt
requires exactly 60 and rejects a malformed hash immediately, so an unknown address
answered in 0.04 ms against 265 ms for a real one. The hash is now generated at runtime
and asserted to be 60 characters (src/lib/crypto.ts). - Locked accounts no longer identify themselves. A locked account returned
423 ACCOUNT_LOCKEDwith a countdown while every other failure returned a generic 401, which
confirmed the address belonged to a real admin. It now returns the same 401 after the
same bcrypt work, and records theadmin.login_failedaudit entry that this path was
previously missing (src/modules/admin/auth.controller.ts). - Client IPs are no longer taken from spoofable headers.
getClientIpread
x-real-ip,cf-connecting-ip,true-client-ipand the leftmostx-forwarded-for
entry before falling back toreq.ip. Nothing strips those on the way in, so any client
could send a fresh value per request to get a clean rate-limit bucket every time, and
write chosen addresses into visit rows, audit entries,lastLoginIpand the IP dedupe on
reports and contact messages. It now usesreq.ip, with an opt-inTRUSTED_IP_HEADER
for deployments behind a proxy that overwrites the header (src/lib/request.ts). - "Revoke sessions" actually revokes sessions now. It incremented
token_versionand
nothing else, butrefresh()never compares a session against that version, so a stolen
refresh token could be exchanged straight back for a working access token carrying the
new version. The operator was told the sessions were revoked while the attacker kept
full console access for up toADMIN_REFRESH_TTL_DAYS. Session rows are now revoked
alongside the bump, and the same sweep runs when an account is deactivated so
deactivate-then-reactivate cannot resurrect outstanding tokens. Signing out everywhere
also bumpstoken_version, so the paired access token dies with it
(src/modules/admin/dashboard.controller.ts,src/modules/admin/auth.controller.ts). - Added CSRF protection to the admin BFF. The proxy and session routes attached the
httpOnly bearer token to any request that arrived with the cookies and never checked
where it came from.SameSite=Laxis a same-site control and the registrable domain is
shared withshort.msyb.dev, so a page on any sibling subdomain could drive every
admin write. Unsafe methods now require a same-originOriginheader, and the sign-in
route requiresapplication/json— without that, atext/plainform post from any
origin could sign an operator into an attacker's account and misattribute everything they
did next (frontend/src/lib/server/session.tsand the two admin route handlers). - Rate-limited the admin auth routes that had no limiter at all.
/auth/logout,
/auth/meand/auth/passwordwere registered above the router-level
adminRouter.use(...), so it never ran for them, andadminRoutermounts above
apiLimiter./auth/passwordruns two bcrypt cost-12 operations per call, making it an
unmetered CPU-exhaustion lever for any token holder, down to themoderatorrole. The
limiter is now mounted first and separately fromrequireAdmin, and password changes get
their own tight per-admin bucket (src/routes/admin.routes.ts). - Bounded the login endpoint. Its only limiter was keyed on
ip:emailread from the
raw body, so varying the email gave an unlimited supply of fresh buckets, each costing a
full bcrypt compare. The key also never trimmed while the account lookup did, so padded
variants of one address were independent buckets that resolved to the same row — enough
to hold any known admin in a permanent lockout. An IP-keyed outer limiter now caps the
work, and the composite key is normalised and hashed (src/middleware/rate-limit.ts). - Closed a limiter bypass in the legacy
/api/shorty-urladapter. It re-dispatched
into the public controllers while running only the generalapiLimiter, so
{"action":"report"}allowed roughly 80x the intended report rate — enough for a small
set of hosts to push arbitrary links pastAUTO_BLOCK_THRESHOLDwith no moderator
involved. It now applies the same limiter instance the equivalent/api/v1route uses,
so quota is shared rather than doubled.POST /api/v1/links/qrhad no limiter either
(src/routes/index.ts). /healthno longer echoes driver errors. It returned the raw message to anonymous
callers, disclosing the database hostname, port and account name
(Access denied for user 'x'@'y'). It is now logged server-side and reduced to
{ ok }in the response (src/app.ts).- The blocklist no longer fails open. A database error while loading it fell back to an
empty list, so on a cold serverless process whose first round-trip times out, every
blocked domain was accepted and permanently minted — surviving the recovery, because
nothing re-checked existing links. A stale list is now served when one exists, and link
creation returns 503 only when the blocklist has never loaded at all
(src/modules/links/service.ts). - Hardened the host check against wildcard DNS.
169-254-169-254.nip.ioand the rest
of that family resolve to the address spelled out in the label, which made the entire
literal-address table optional for anyone willing to paste a different hostname. Those
services are refused, as is any host embedding a private dotted-quad. Added the 6to4
(2002::/16), Teredo (2001::/32), site-local (fec0::/10) and discard (100::/64)
ranges, which reach the same private destinations by another route
(src/lib/url-safety.ts). - Closed an SSRF bypass for IPv4-mapped IPv6 addresses. The guard matched
::ffff:only in dotted-quad form, but the WHATWG URL parser re-serialises
[::ffff:127.0.0.1]to[::ffff:7f00:1], so[::ffff:a9fe:a9fe]reached the cloud
metadata address unchallenged. Range checks now usenet.BlockList, which resolves
IPv4-mapped addresses against the IPv4 rules, and the deprecated IPv4-compatible
(::/96) and NAT64 (64:ff9b::/96) ranges are covered too (src/lib/url-safety.ts).
Added
SECURITY.md,CONTRIBUTING.md,CODE_OF_CONDUCT.mdand this changelog.- GitHub issue and pull request templates,
CODEOWNERS, and a Dependabot configuration
covering both services. frontend/scripts/generate-og.mjs, which renders the social card at both the
OpenGraph (1200×630) and GitHub social preview (1280×640) sizes.- Root
.nvmrcpinning Node 22. - First tests:
server/vitest.config.tsplus 24 cases acrossurl-safetyandcrypto,
pinning each bypass above as a regression test.npm testpreviously exited 1 because
the vitest script had no test files to run. TRUSTED_IP_HEADER, an opt-in setting for reading the client IP from a proxy header.
Changed
- Rewrote the root README: live demo and quick-start call-to-action, dynamic badges,
screenshots (previously present in the repository but referenced nowhere), an
architecture section, a roadmap and an FAQ. - Recaptured all four screenshots against the live 3.x site — the previous set predated
the Next.js 16 rewrite. Re-encoded to WebP at 1600px, which took the set from 14.6 MB
to 278 KB. - Corrected three overstated security claims in the README — password hashing is bcrypt,
not Argon2; the strictdefault-src 'none'CSP applies to the API, while the web app
permits inline script for Ant Design and the no-flash theme script. - Documented the
CREATE DATABASEstep thatnpm run db:setuprequires, and added a
platform-independent alternative toopenssl randfor generatingADMIN_JWT_SECRET. LICENSEcopyright now reads2023-present.
Removed
- The
lintscript infrontend/package.json.next lintwas removed in Next.js 16, so
the script errored out; there is no ESLint configuration to replace it with yet.