Skip to content

Security: cole-hackman/tracktoolkit

Security

docs/SECURITY.md

Security Implementation

This document outlines the security measures implemented in the Track Toolkit project.

1. Rate Limiting

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
  • Authentication Rate Limiter: 5 attempts per 15 minutes per IP

    • Applied to /api/auth/* routes
    • Stricter limits for login endpoints
    • Skips counting successful requests
  • 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)
  • Health Check Rate Limiter: 60 requests per minute per IP

    • Applied to /health endpoint
    • Prevents health check abuse while allowing monitoring
    • Skips counting successful requests

Rate limiters use IP addresses for identification, with support for proxy headers (X-Forwarded-For).

2. Input Validation

Comprehensive input validation has been implemented using express-validator:

Validated Endpoints

  • 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.

3. API Key Protection

Server-Side Only

  • All API keys and secrets are stored in environment variables
  • Never exposed in client-side code or API responses
  • .env files are gitignored (.gitignore includes .env*)

Secrets Management

  • SOUNDCLOUD_CLIENT_ID: Public OAuth client ID (safe to expose in URLs)
  • SOUNDCLOUD_CLIENT_SECRET: Server-side only, never sent to client
  • ENCRYPTION_KEY: Used only for token encryption at rest
  • SESSION_SECRET: Used only for session signing

Response Sanitization

  • 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

Secure Logging

  • 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

Client ID Usage

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.

4. SQL Injection Protection

Prisma ORM

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

Verified Safe Queries

All database operations use Prisma methods:

  • prisma.user.findUnique() - User lookup by ID
  • prisma.user.upsert() - User creation/update
  • prisma.token.upsert() - Token storage

Raw SQL

  • 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 (the COUNT(DISTINCT "userId") snapshot), server/lib/catalog.js and server/lib/enrichment.js (upserts Prisma cannot express), server/routes/admin.js (aggregate reports) and server/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's genre and artist filters are necessarily free text; they reach an ILIKE through a tagged template, so they are parameterised, not enumerated

5. Additional Security Measures

Security Headers (Helmet)

  • 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, in connectSrc
  • frame-src is 'none' on every page except the /admin document, which is served a second helmet instance adding frame-src https://w.soundcloud.com for 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.js pins both branches and asserts every other directive is identical between them
  • tests/security-headers.test.js sweeps scriptSrc, styleSrc, connectSrc, fontSrc and frameSrc for 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)

Error Handling

  • 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

Authentication

  • 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

CORS

  • Strict origin whitelist
  • Credentials required for authenticated requests
  • Hostname-based validation for resilience

Security Checklist

  • ✅ 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

Recent Security Enhancements

Secure Logging System

  • 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

Enhanced Environment Variable Validation

  • 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

Error Message Sanitization

  • SoundCloud API error responses no longer include full response bodies
  • Error messages sanitized to prevent token/secret leakage
  • All error logging uses secure logger

Complete Rate Limiting Coverage

  • Health check endpoint now has rate limiting (60 requests/minute)
  • All endpoints verified to have appropriate rate limiting

Recommendations

  1. Regular Security Audits: Periodically review dependencies for vulnerabilities (npm audit)
  2. Monitoring: Set up monitoring for rate limit violations and suspicious activity
  3. Secrets Rotation: Rotate API keys and secrets periodically
  4. HTTPS: Ensure HTTPS is enforced in production (handled by hosting provider)
  5. Log Monitoring: Monitor secure logs for patterns indicating security issues
  6. Security Testing: .github/workflows/azure-deploy.yml runs the Jest suite on every push to main and a failure blocks the deploy, so the authz/CSRF/CSP boundary tests under tests/routes/ and tests/security-headers.test.js do gate a release. Dependency scanning (npm audit) and the frontend's own checks are still manual.

CSRF model

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.

Session lifetime

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.

Account lifecycle: disconnect, revocation, export

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:

  1. a 400 or 401 whose JSON body is {"error":"invalid_grant"};
  2. a 401 with 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:

  1. Every query is scoped to req.user.id; no identifier is read from the request, so there is nothing to tamper with.
  2. The token record contributes expiresAt and nothing else. encrypted and refresh are AES-256-GCM ciphertext of live credentials and are excluded at the select, so they never leave Postgres. Note that req.user is the full user row with its tokens relation 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.

There aren't any published security advisories