Skip to content

Security

Threat Model

Playupi’s attack surface includes:

SurfaceRiskPriority
Game iframesMalicious game code, XSS via iframe escape, clickjackingHigh
Event ingestion APISpam, fake metrics, bot trafficHigh
Admin dashboardUnauthorized access, privilege escalationCritical
User inputXSS via search, game titles, descriptionsMedium
InfrastructureData leaks, unpatched dependencies, misconfigurationsMedium

Iframe Security

Games run inside iframes from external sources. This is the highest-risk surface.

Sandbox Attributes

All game iframes must use restrictive sandbox attributes:

<iframe
src="https://..."
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
referrerpolicy="no-referrer"
loading="lazy"
></iframe>
AttributeWhy
allow-scriptsGames need JavaScript to run
allow-same-originRequired for some game engines to function (localStorage, cookies)
allow-popupsSome games open links (e.g., developer website)
allow-popups-to-escape-sandboxAllows popup windows to not inherit sandbox
Omitted: allow-formsGames should not submit forms to external servers
Omitted: allow-top-navigationPrevents iframe from redirecting the parent page

Content Security Policy

Content-Security-Policy:
default-src 'self';
frame-src https://*.gamedistribution.com https://*.html5games.com;
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' https: data:;
connect-src 'self' https://api.playupi.com;
frame-ancestors 'none';
DirectivePurpose
frame-srcWhitelist allowed iframe sources (broker domains)
frame-ancestors 'none'Prevent Playupi from being embedded in other sites (clickjacking)
connect-srcRestrict API calls to own domain

Iframe Communication

  • Never use window.postMessage with * as target origin
  • Validate message origin against a whitelist of broker domains
  • Treat all messages from iframes as untrusted input
window.addEventListener('message', (event) => {
if (!ALLOWED_ORIGINS.includes(event.origin)) return
const data = validateBrokerMessage(event.data)
if (!data) return
// Process validated message
})

Admin Authentication

Auth Flow

Admin login
POST /api/admin/auth/login { email, password }
Verify password (bcrypt, cost factor 12)
Issue JWT (short-lived: 1 hour)
Store refresh token (httpOnly, secure, sameSite=strict cookie)
JWT included in Authorization header for all admin requests

Token Security

TokenStorageLifetimeRefresh
Access token (JWT)Memory (JS variable)1 hourVia refresh token
Refresh tokenhttpOnly secure cookie7 daysRe-login required

Password Requirements

RuleValue
Minimum length12 characters
Hashingbcrypt with cost factor 12
Brute force protectionAccount lockout after 5 failed attempts (15 min cooldown)

Session Invalidation

  • Logout clears refresh token cookie and invalidates server-side
  • Password change invalidates all existing sessions
  • Admin can revoke other admin sessions

Rate Limiting

Public API

EndpointLimitWindowKey
GET /api/v1/games60 req1 minIP
GET /api/v1/search30 req1 minIP
POST /api/v1/events100 req1 minIP
POST /api/v1/events500 events1 minSession ID
POST /api/v1/games/:id/like10 req1 minIP

Admin API

EndpointLimitWindowKey
POST /api/admin/auth/login5 req15 minIP
All admin endpoints120 req1 minUser ID

Implementation

Use Redis-based sliding window rate limiting:

// Middleware
async function rateLimit(key: string, limit: number, windowMs: number) {
const current = await redis.incr(key)
if (current === 1) {
await redis.pexpire(key, windowMs)
}
if (current > limit) {
throw new RateLimitError()
}
}

Return 429 Too Many Requests with Retry-After header.


Input Validation

General Rules

  • Validate all input at the API boundary
  • Use schema validation (Zod) for request bodies
  • Sanitize strings before database insertion
  • Never trust client-side validation alone

Search Input

const searchSchema = z.object({
q: z.string().min(2).max(100).trim(),
type: z.enum(['games', 'categories', 'authors']).optional(),
limit: z.number().int().min(1).max(20).default(5)
})

Admin Input

const updateGameSchema = z.object({
title: z.string().min(1).max(200).trim().optional(),
description: z.string().max(5000).trim().optional(),
visibility: z.enum(['draft', 'hidden', 'visible']).optional(),
categories: z.array(z.string().uuid()).max(10).optional(),
iframeUrl: z.string().url().optional()
})

Iframe URL Validation

Before storing or rendering an iframe URL:

function validateIframeUrl(url: string): boolean {
const parsed = new URL(url)
// Must be HTTPS
if (parsed.protocol !== 'https:') return false
// Must be from an allowed domain
if (!ALLOWED_IFRAME_DOMAINS.some(d => parsed.hostname.endsWith(d))) return false
// No javascript: or data: protocols in the path
if (/^(javascript|data):/i.test(url)) return false
return true
}

XSS Prevention

Output Encoding

  • All dynamic content rendered via React (auto-escapes by default)
  • Never use dangerouslySetInnerHTML with user-provided content
  • Game descriptions and instructions stored as plain text, rendered with whitespace preservation — not as HTML or Markdown

Headers

X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0 // Deprecated but harmless
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()

CORS Configuration

APIAllowed OriginsMethods
Public (/api/v1)https://playupi.comGET, POST
Admin (/api/admin)https://admin.playupi.comGET, POST, PATCH, DELETE
Preview (dev only)https://*.vercel.appAll
  • Credentials are not sent cross-origin (JWT is in Authorization header, not cookies)
  • Access-Control-Max-Age: 86400 to reduce preflight requests
  • Preview deploy origins are only allowed in non-production environments
  • See API Reference → CORS Policy for the full header configuration

CSRF Protection

  • Admin API uses JWT in Authorization header (not cookies) for state-changing requests — inherently CSRF-resistant
  • Refresh token cookie uses SameSite=Strict
  • Public event API is idempotent and uses session IDs — no CSRF risk

Event Anti-Abuse

Bot Detection

SignalAction
No JavaScript execution (headless)Block at CDN level (Cloudflare Bot Management)
Events from non-existent sessionsDrop silently
Timestamp drift > 24h from server timeDrop event
Identical events within 1s windowDeduplicate
> 500 events/min from single sessionRate limit

Metric Integrity

  • Events are append-only — no client can modify or delete past events
  • Aggregation jobs process events idempotently (re-running produces the same result)
  • Anomaly detection flag: if a game’s DAU spikes > 10x overnight without ranking change, flag for review

Fingerprinting

Anonymous device fingerprinting uses a combination of:

  • Random UUID stored in localStorage (primary)
  • Fallback: basic browser fingerprint (screen size, timezone, language) hashed

This is used for session continuity and deduplication, not for cross-site tracking.


Data Privacy

MVP (Pre-GDPR)

RuleImplementation
No personal data collectedNo accounts, no emails, no names
Anonymous tracking onlyDevice fingerprint (random UUID in localStorage)
No third-party trackersNo Google Analytics, no Facebook Pixel
Cookie-lessOnly httpOnly cookies for admin auth

Future (GDPR Compliance)

When user accounts are added:

RequirementImplementation
Consent bannerBefore any tracking begins
Data exportAPI endpoint to export all data for a user
Right to deletionAPI endpoint to delete all user data
Data retentionClear events older than retention policy
Privacy policyAccessible from footer on all pages
DPA with providersData Processing Agreements with all third-party services

See Decisions for GDPR status.


Dependency Security

PracticeFrequency
npm auditOn every CI run
Dependabot / RenovateEnabled for automated PRs
Lock file committedAlways (package-lock.json or pnpm-lock.yaml)
Review new dependenciesBefore adding — check bundle size, maintenance status, license
Pin major versionsAvoid unexpected breaking changes

Supply Chain

  • Use npm with --ignore-scripts during CI to prevent install-time attacks
  • Pin exact versions for critical dependencies (ORM, auth, crypto)
  • Audit transitive dependencies periodically

Infrastructure Security

Secrets Management

SecretStorageAccess
Database URLEnvironment variableBackend only
Redis URLEnvironment variableBackend only
JWT signing keyEnvironment variableBackend only
Admin initial passwordOne-time setup, not stored in codeN/A
Broker API keysEnvironment variableBackend only

Never commit secrets to git. Use .env.local for development, platform-native secret management for production (Vercel env vars, Railway secrets, etc.).

Network

RuleImplementation
HTTPS everywhereEnforced at CDN/proxy level
Database not publicly accessibleInternal network only, accessed via connection string
Admin API on separate subdomainadmin-api.playupi.com — easier to restrict at network level
SSH/console accessKey-based only, no password auth

Logging

What to logWhat NOT to log
Authentication attempts (success + failure)Passwords or password hashes
Admin actions (game create, visibility change, delete)Full request bodies with sensitive data
Rate limit triggersSession tokens or JWTs
Error stack traces (server-side only)User IP addresses (hash them)
Unusual event patterns

Incident Response

Severity Levels

LevelExampleResponse TimeAction
P0 — CriticalData breach, admin auth bypass, full site downImmediateAll hands, status page update, public comms if user data affected
P1 — HighEvent ingestion down, ranking broken, partial outage< 1 hourOn-call investigates, hotfix deploy
P2 — MediumElevated error rate, degraded performance, bot spam< 4 hoursInvestigate during business hours
P3 — LowMinor UI bug, non-critical feature brokenNext sprintTrack in issue tracker

Response Steps

  1. Detect — Sentry alerts, Betterstack uptime, user reports
  2. Assess — Determine severity, scope, and blast radius
  3. Contain — Block attack vector (rate limit, disable endpoint, revoke tokens)
  4. Fix — Deploy hotfix or rollback
  5. Review — Post-incident write-up within 48 hours (what happened, why, how to prevent)

MVP Contacts

Maintain a simple on-call list (even if it’s just 1-2 people) with phone numbers and escalation order. Store it outside the platform itself (e.g., shared note, team chat pinned message).


Security Checklist (Pre-Launch)

ItemStatus
All iframes use sandbox attributes
CSP headers configured
Admin auth with bcrypt + JWT
Rate limiting on all public endpoints
Input validation with Zod on all routes
Iframe URL whitelist enforced
Security headers set (X-Frame-Options, etc.)
npm audit passes with 0 critical
No secrets in git history
HTTPS enforced
Database not publicly accessible
Admin actions logged
Error pages don’t leak stack traces