This document outlines the security measures implemented in the Track Toolkit project.
Rate limiting has been implemented using express-rate-limit to protect against abuse and DoS attacks:
-
General API Rate Limiter: 100 requests per 15 minutes per IP
- Applied to all
/api/*routes - Prevents general API abuse
- Applied to all
-
Authentication Rate Limiter: 5 attempts per 15 minutes per IP
- Applied to
/api/auth/*routes - Stricter limits for login endpoints
- Skips counting successful requests
- Applied to
-
Heavy Operations Rate Limiter: 20 requests per hour per IP
- Applied to resource-intensive endpoints:
/api/resolve(URL resolution)/api/playlists/merge(playlist merging)/api/playlists/from-likes(creating playlists from likes)
- Applied to resource-intensive endpoints:
-
Health Check Rate Limiter: 60 requests per minute per IP
- Applied to
/healthendpoint - Prevents health check abuse while allowing monitoring
- Skips counting successful requests
- Applied to
Rate limiters use IP addresses for identification, with support for proxy headers (X-Forwarded-For).
Comprehensive input validation has been implemented using express-validator:
-
Playlist Operations:
- Playlist IDs validated as positive integers
- Track arrays validated with length limits (max 500 tracks)
- Titles sanitized and length-limited (max 200 characters)
-
URL Resolution:
- URLs validated for format and length (max 2048 characters)
- Domain whitelist enforced (only SoundCloud domains allowed)
- XSS prevention through input sanitization
-
Pagination:
- Limit values validated (1-200 range)
- Offset values validated as non-negative integers
- Cursor-based pagination URLs validated
-
Merge Operations:
- Source playlist IDs validated (2-10 playlists)
- All IDs validated as positive integers
- Title sanitization applied
All validation errors return structured error responses with field-level details.
- All API keys and secrets are stored in environment variables
- Never exposed in client-side code or API responses
.envfiles are gitignored (.gitignoreincludes.env*)
SOUNDCLOUD_CLIENT_ID: Public OAuth client ID (safe to expose in URLs)SOUNDCLOUD_CLIENT_SECRET: Server-side only, never sent to clientENCRYPTION_KEY: Used only for token encryption at restSESSION_SECRET: Used only for session signing
- Middleware (
preventKeyLeakage) automatically removes potential secrets from API responses - Error messages are sanitized to prevent information leakage
- Environment variable validation ensures required secrets are present
- Custom secure logging utility (
server/lib/logger.js) sanitizes all log output - Automatically removes secrets, tokens, and sensitive data from error logs
- Stack traces only included in development mode
- Recursive object sanitization prevents deep secret leakage
- All
console.error()calls replaced with secure logger
The client_id is used in public resolve endpoints (SoundCloud API requirement), which is acceptable as OAuth client IDs are public by design. The client_secret is never exposed.
All database queries use Prisma ORM, which provides automatic protection against SQL injection:
- Parameterized Queries: Prisma automatically uses parameterized queries
- Type Safety: TypeScript/JavaScript type checking prevents invalid inputs
- Query Builder: No raw SQL strings are constructed from user input
All database operations use Prisma methods:
prisma.user.findUnique()- User lookup by IDprisma.user.upsert()- User creation/updateprisma.token.upsert()- Token storage
- Most queries use Prisma's type-safe query builder
- Raw SQL does exist, and every occurrence is a tagged template —
prisma.$queryRaw\…`/$executeRaw`…`orPrisma.sql`…`— which parameterises its interpolations. The unsafe variants ($queryRawUnsafe,$executeRawUnsafe`) are not used anywhere, and string-concatenated SQL is not either - Call sites:
server/lib/retention.js(theCOUNT(DISTINCT "userId")snapshot),server/lib/catalog.jsandserver/lib/enrichment.js(upserts Prisma cannot express),server/routes/admin.js(aggregate reports) andserver/scripts/backfill-track-catalog.js - User input is validated before database operations. The admin list filters
that are enumerable —
status,type,period— accept only their enumerated values and drop anything else rather than passing it to Prisma. The catalog'sgenreandartistfilters are necessarily free text; they reach anILIKEthrough a tagged template, so they are parameterised, not enumerated
- Content Security Policy configured in
server/middleware/security.js. It names no third-party script, style or font source — that is the enforcement behind the privacy policy's plain-language promise that the app runs no analytics and no advertising scripts. The only external host in it is SoundCloud, inconnectSrc frame-srcis'none'on every page except the/admindocument, which is served a second helmet instance addingframe-src https://w.soundcloud.comfor the embedded player and nothing else. A CSP governs the document it is served with, so this cannot widen any other page.tests/routes/csp-admin-frame.test.jspins both branches and asserts every other directive is identical between themtests/security-headers.test.jssweepsscriptSrc,styleSrc,connectSrc,fontSrcandframeSrcfor known tracker, widget and font-host names, and checks the admin frame allowance by value — a test that compared it to the constant that produced it would pass for any value that constant was given- XSS protection headers
- MIME type sniffing prevention
- Frame options (clickjacking protection)
- Generic error messages in production
- Detailed errors only in development mode
- No stack traces exposed to clients
- Secrets filtered from error responses
- Secure logging utility sanitizes all error logs before output
- SoundCloud API error responses sanitized (response bodies not included in error messages)
- All error logging uses secure logger to prevent secret leakage
- Session cookies are HttpOnly and Secure
- PKCE flow for OAuth (prevents authorization code interception)
- Tokens encrypted at rest using AES-256-GCM
- Automatic token refresh on expiration
- Strict origin whitelist
- Credentials required for authenticated requests
- Hostname-based validation for resilience
- ✅ Rate limiting implemented on all API endpoints (including health check)
- ✅ Input validation on all user inputs
- ✅ API keys stored in environment variables only
- ✅ No secrets exposed in client-side code
- ✅ Response sanitization prevents key leakage
- ✅ SQL injection protection via Prisma ORM
- ✅ Security headers via Helmet
- ✅ Error handling prevents information leakage
- ✅ Enhanced environment variable validation (format, length, empty string checks)
- ✅ CORS properly configured
- ✅ Secure logging utility prevents secret leakage in logs
- ✅ All error messages sanitized before logging
- ✅ SoundCloud API error responses sanitized
- Implemented custom secure logging utility (
server/lib/logger.js) - All error logging now sanitizes secrets, tokens, and sensitive data
- Recursive object sanitization prevents deep secret leakage
- Stack traces only included in development mode
- Validates not just existence, but also format and length
- Encryption key must be exactly 32 characters
- Session secret must be at least 32 characters
- Database URL format validation
- Client secret minimum length validation
- Redirect URI must be valid HTTP/HTTPS URL
- SoundCloud API error responses no longer include full response bodies
- Error messages sanitized to prevent token/secret leakage
- All error logging uses secure logger
- Health check endpoint now has rate limiting (60 requests/minute)
- All endpoints verified to have appropriate rate limiting
- Regular Security Audits: Periodically review dependencies for vulnerabilities (
npm audit) - Monitoring: Set up monitoring for rate limit violations and suspicious activity
- Secrets Rotation: Rotate API keys and secrets periodically
- HTTPS: Ensure HTTPS is enforced in production (handled by hosting provider)
- Log Monitoring: Monitor secure logs for patterns indicating security issues
- Security Testing:
.github/workflows/azure-deploy.ymlruns the Jest suite on every push tomainand a failure blocks the deploy, so the authz/CSRF/CSP boundary tests undertests/routes/andtests/security-headers.test.jsdo gate a release. Dependency scanning (npm audit) and the frontend's own checks are still manual.
Production cookies are SameSite=Lax since the Azure cutover — the frontend
and the API are one origin (https://tracktoolkit.com), so there is no
cross-site request to carry a session cookie on. That is now the first layer,
but it is set from an environment variable (SESSION_COOKIE_SAMESITE) and
resolveSessionSameSite still defaults to none in production when it is
unset, so the two layers built for the split-host deployment stay and are
still the ones under test: (1) rejectUntrustedOrigin
middleware rejects state-changing /api requests whose Origin header is not
in the allowlist; (2) express.json() is deliberately the ONLY body parser —
cross-site HTML form posts (urlencoded/text-plain) parse to an empty body and
every mutating route's validator fails closed. Do not add
express.urlencoded() without revisiting this section.
tests/routes/origin.test.js and tests/routes/feedback-authz.test.js are
the regression tests for both layers.
Sessions are HMAC-SHA256-signed cookies carrying iat; they expire 7 days
after issuance server-side (SESSION_TTL_MS) regardless of cookie replay.
Known limitation: there is no server-side revocation list — logout clears the
cookie but a previously exfiltrated cookie stays valid until its TTL.
Three paths now exist where there used to be two (server/routes/auth.js,
server/lib/account-lifecycle.js).
Disconnect — POST /api/auth/disconnect. Logout forgets the session
cookie and nothing else: the encrypted token pair stays in the database and
the next login picks it straight back up. Disconnect hands the SoundCloud
grant back (POST https://api.soundcloud.com/sign-out, best-effort, on its
own 5s deadline), deletes the tokens row, and stamps users.disconnectedAt.
The account survives — a later login clears the stamp — but if nobody comes
back the retention job deletes the row (and everything cascading from it)
after six days. Six, not seven: SoundCloud's terms give seven, the sweep runs
daily, so the real worst case is the window plus up to one sweep interval, and
seven would have been up to eight. RETENTION_INTERVAL_MS is clamped to a 24h
maximum in code for the same reason. See docs/internal/TERMS-CHECK.md
finding B.
disconnectUser() calls invalidateCachedAuth(userId). This is not optional:
server/lib/auth-cache.js memoizes the decrypted token pair for 30
seconds, so without it a request arriving inside that window would keep
working against tokens that no longer exist. It runs inside a finally
wrapped around the token delete, i.e. before the user update and the cache
teardown, so a failure anywhere later in the function cannot leave the memo
serving credentials whose database row is already gone. disconnectUser also
drops the library request cache, the invalidation marks, and the durable
snapshot tier, since all of it is derived from the grant just returned.
The route takes no body, so the second CSRF layer (empty body → validator
fails closed) has nothing to act on. rejectUntrustedOrigin is the whole
guard, and tests/routes/account-deletion.test.js asserts a cross-site POST
is refused with 403 before the handler runs.
Revocation detection. A user can also revoke the app from SoundCloud's own
settings page, which this service never hears about directly. It is detected
at the single refresh choke point (refreshTokensAndPersist). Exactly two
responses count, and the rule is deliberately narrow:
- a
400or401whose JSON body is{"error":"invalid_grant"}; - a
401with an empty body.
The teardown then runs with reason: 'revoked' and no sign-out call, because
the token is already dead. The thrown error is unchanged, so the request
surfaces exactly as it always did.
What deliberately does not trigger it: a 401 with a non-empty body that
is not JSON, plus 429, every 5xx, timeouts and network failures. The
non-JSON 401 matters most — that shape is an HTML error page from a proxy,
WAF or load balancer in front of the token endpoint far more often than it is
a revocation, and treating it as one would destroy a live user's tokens
because of someone else's infrastructure. Disconnecting people over a
transient upstream failure would be a self-inflicted outage.
tests/routes/token-refresh.test.js pins every branch, and
tests/soundcloud-signout.test.js holds the full truth table for
isInvalidGrantResponse.
Export — GET /api/auth/export, heavyOperationRateLimiter. Returns
every row keyed to the caller as one JSON attachment. Two invariants, both
asserted in tests/routes/export.test.js:
- Every query is scoped to
req.user.id; no identifier is read from the request, so there is nothing to tamper with. - The token record contributes
expiresAtand nothing else.encryptedandrefreshare AES-256-GCM ciphertext of live credentials and are excluded at theselect, so they never leave Postgres. Note thatreq.useris the full user row with itstokensrelation included — spreading it into the payload would ship both ciphertexts, which is why the route names fields explicitly.
Retention (server/lib/retention.js) enforces the stated windows daily.
Relevant to this document: it deletes disconnected accounts after 6 days,
dormant accounts after INACTIVE_MONTHS, and nulls the one free-text PII
column left in the retired beta-survey table on every run.