Initial commit - realms platform

This commit is contained in:
doomtube 2026-01-05 22:54:27 -05:00
parent c590ab6d18
commit c717c3751c
234 changed files with 74103 additions and 15231 deletions

View file

@ -1,17 +1,20 @@
FROM openresty/openresty:alpine
# Install dependencies needed by opm
RUN apk add --no-cache curl perl
# Install dependencies needed by opm and thumbnail generation
RUN apk add --no-cache curl perl ffmpeg
# Install lua-resty-http
RUN opm get ledgetech/lua-resty-http
# Install lua-resty-jwt for edge JWT validation
RUN opm get SkyLothar/lua-resty-jwt
# Copy configuration
COPY nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
COPY lua /usr/local/openresty/nginx/lua
# Create uploads directory structure with proper permissions
RUN mkdir -p /app/uploads/avatars && \
RUN mkdir -p /app/uploads/avatars /app/uploads/stickers /app/uploads/sticker-submissions && \
chmod -R 755 /app/uploads
# Create nginx temp directories
@ -22,7 +25,10 @@ RUN mkdir -p /var/cache/nginx/client_temp \
/var/cache/nginx/scgi_temp && \
chmod -R 755 /var/cache/nginx
# Create startup script inline to avoid Windows line ending issues
RUN printf '#!/bin/sh\nmkdir -p /tmp/thumbs\nchmod 777 /tmp/thumbs\nexec /usr/local/openresty/bin/openresty -g "daemon off;"\n' > /start.sh && chmod +x /start.sh
EXPOSE 80 443
# Run as root but nginx will drop privileges after binding to ports
CMD ["/usr/local/openresty/bin/openresty", "-g", "daemon off;"]
# Use startup script to ensure proper permissions on /tmp/thumbs
CMD ["/start.sh"]

View file

@ -0,0 +1,38 @@
# SSL/HTTPS Server Block Template
# This file is used as a template when SSL certificates are available
# The domain placeholder __DOMAIN__ will be replaced with the actual domain
server {
listen 443 ssl http2;
server_name __DOMAIN__;
# SSL Certificate paths (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/__DOMAIN__/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/__DOMAIN__/privkey.pem;
# Modern SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# SSL session settings
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Include the main location blocks
# These should match your HTTP server configuration
include /usr/local/openresty/nginx/conf/locations.conf;
}

View file

@ -0,0 +1,71 @@
-- Server-side fingerprint generation
-- Generates a consistent fingerprint from IP + User-Agent + Accept-Language
-- This cannot be spoofed by clients as it's computed server-side
local resty_sha256 = require "resty.sha256"
local str = require "resty.string"
local _M = {}
-- Cache fingerprints to avoid recomputation (10 min TTL)
local fingerprint_cache = ngx.shared.fingerprints
-- Generate a fingerprint from request characteristics
-- Uses: Client IP, User-Agent, Accept-Language
function _M.generate(custom_ip)
-- Get client IP (respects X-Real-IP set by upstream proxy)
local client_ip = custom_ip or ngx.var.remote_addr
-- Get User-Agent (empty string if not present)
local user_agent = ngx.var.http_user_agent or ""
-- Get Accept-Language for additional entropy
local accept_lang = ngx.var.http_accept_language or ""
-- Create a cache key from the raw inputs
local cache_key = client_ip .. "|" .. user_agent .. "|" .. accept_lang
-- Check cache first
if fingerprint_cache then
local cached = fingerprint_cache:get(cache_key)
if cached then
return cached
end
end
-- Generate SHA256 hash
local sha256 = resty_sha256:new()
if not sha256 then
ngx.log(ngx.ERR, "Failed to create SHA256 instance")
return nil
end
-- Hash the combined data
sha256:update(client_ip)
sha256:update("|")
sha256:update(user_agent)
sha256:update("|")
sha256:update(accept_lang)
local digest = sha256:final()
local fingerprint = str.to_hex(digest)
-- Cache for 10 minutes (600 seconds)
if fingerprint_cache then
fingerprint_cache:set(cache_key, fingerprint, 600)
end
return fingerprint
end
-- Inject fingerprint header for proxied requests
-- Call this in access_by_lua_block before proxy_pass
function _M.inject_header()
local fp = _M.generate()
if fp then
ngx.req.set_header("X-Server-Fingerprint", fp)
end
return fp
end
return _M

167
openresty/lua/jwt.lua Normal file
View file

@ -0,0 +1,167 @@
-- JWT validation module for OpenResty edge authentication
-- Validates JWT tokens before requests hit the backend
local jwt = require "resty.jwt"
local _M = {}
-- Get JWT secret (cached after first call)
local jwt_secret_cache = nil
local function get_jwt_secret()
if jwt_secret_cache then
return jwt_secret_cache
end
-- os.getenv works in OpenResty when 'env JWT_SECRET;' is in nginx.conf
local secret = os.getenv("JWT_SECRET")
if secret and secret ~= "" then
jwt_secret_cache = secret
ngx.log(ngx.INFO, "JWT_SECRET loaded successfully (", string.len(secret), " chars)")
return secret
end
ngx.log(ngx.ERR, "JWT_SECRET environment variable not set or empty")
return nil
end
-- Validate JWT and inject user headers for backend
-- Call this in access_by_lua_block for authenticated endpoints
function _M.validate_and_inject()
-- Get token from httpOnly cookie (name: auth_token)
local token = ngx.var.cookie_auth_token
if not token or token == "" then
ngx.log(ngx.DEBUG, "No auth_token cookie found")
ngx.status = 401
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error":"No authentication token"}')
return ngx.exit(401)
end
-- Get JWT secret
local secret = get_jwt_secret()
if not secret then
-- SECURITY FIX: Fail closed - do NOT allow requests through if secret is missing
-- This prevents authentication bypass if env var is misconfigured
ngx.log(ngx.ERR, "JWT_SECRET not configured - blocking request")
ngx.status = 500
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error":"Server configuration error"}')
return ngx.exit(500)
end
-- Verify JWT signature and decode
local jwt_obj = jwt:verify(secret, token)
if not jwt_obj then
ngx.log(ngx.ERR, "JWT verification returned nil")
ngx.status = 401
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error":"Invalid token"}')
return ngx.exit(401)
end
if not jwt_obj.verified then
local reason = jwt_obj.reason or "unknown"
ngx.log(ngx.WARN, "JWT verification failed: ", reason)
ngx.status = 401
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error":"Invalid token: ' .. reason .. '"}')
return ngx.exit(401)
end
-- Payload should exist if verified
local payload = jwt_obj.payload
if not payload then
ngx.log(ngx.ERR, "JWT verified but no payload")
ngx.status = 401
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error":"Invalid token structure"}')
return ngx.exit(401)
end
-- Check expiry (jwt library should handle this, but be safe)
local exp = payload.exp
if exp then
local now = ngx.time()
if exp < now then
ngx.log(ngx.DEBUG, "JWT expired: exp=", exp, " now=", now)
ngx.status = 401
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error":"Token expired"}')
return ngx.exit(401)
end
end
-- Check issuer
local iss = payload.iss
if iss ~= "streaming-app" then
ngx.log(ngx.WARN, "Invalid JWT issuer: ", tostring(iss))
ngx.status = 401
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error":"Invalid token issuer"}')
return ngx.exit(401)
end
-- SECURITY: Check if user account is disabled
local is_disabled = payload.is_disabled
if is_disabled == "1" or is_disabled == true then
ngx.log(ngx.WARN, "Disabled user attempted access: ", tostring(payload.username))
ngx.status = 403
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error":"Account disabled"}')
return ngx.exit(403)
end
-- Inject user info headers for backend (allows backend to skip re-validation)
ngx.req.set_header("X-User-ID", tostring(payload.user_id or ""))
ngx.req.set_header("X-Username", tostring(payload.username or ""))
ngx.req.set_header("X-Is-Admin", tostring(payload.is_admin or "0"))
ngx.req.set_header("X-Is-Moderator", tostring(payload.is_moderator or "0"))
ngx.req.set_header("X-Is-Streamer", tostring(payload.is_streamer or "0"))
ngx.req.set_header("X-Is-Restreamer", tostring(payload.is_restreamer or "0"))
ngx.req.set_header("X-Token-Version", tostring(payload.token_version or "1"))
ngx.req.set_header("X-User-Color", tostring(payload.color_code or "#561D5E"))
ngx.req.set_header("X-Avatar-URL", tostring(payload.avatar_url or ""))
ngx.req.set_header("X-JWT-Validated", "true")
ngx.log(ngx.DEBUG, "JWT validated for user: ", tostring(payload.username))
end
-- Optional: Get user info without blocking (for endpoints that work with or without auth)
-- Returns user payload if authenticated, nil otherwise
function _M.get_user_if_authenticated()
local token = ngx.var.cookie_auth_token
if not token or token == "" then
return nil
end
local secret = get_jwt_secret()
if not secret then
return nil
end
local jwt_obj = jwt:verify(secret, token)
if not jwt_obj or not jwt_obj.verified then
return nil
end
local payload = jwt_obj.payload
if not payload then
return nil
end
-- Check expiry
if payload.exp and payload.exp < ngx.time() then
return nil
end
-- Check disabled
local is_disabled = payload.is_disabled
if is_disabled == "1" or is_disabled == true then
return nil
end
return payload
end
return _M

View file

@ -2,19 +2,33 @@ local redis = require "resty.redis"
local _M = {}
-- Cache password at module load time (works better with OpenResty)
local REDIS_PASSWORD = os.getenv("REDIS_PASS")
local function get_redis_connection()
local red = redis:new()
red:set_timeouts(1000, 1000, 1000) -- connect, send, read timeout in ms
local host = "redis" -- Will be resolved by nginx resolver
local port = tonumber(os.getenv("REDIS_PORT")) or 6379
local ok, err = red:connect(host, port)
if not ok then
ngx.log(ngx.ERR, "Failed to connect to Redis: ", err)
return nil
end
-- Authenticate if password is set
if REDIS_PASSWORD and REDIS_PASSWORD ~= "" then
local res, err = red:auth(REDIS_PASSWORD)
if not res then
ngx.log(ngx.ERR, "Failed to authenticate to Redis: ", err)
return nil
end
else
ngx.log(ngx.WARN, "No Redis password set, trying without auth")
end
return red
end
@ -69,7 +83,8 @@ function _M.validate_viewer_token(token, expected_stream_key)
-- Check if the token is for the expected stream
if res ~= expected_stream_key then
ngx.log(ngx.WARN, "Token stream mismatch. Expected: ", expected_stream_key, " Got: ", res)
-- SECURITY FIX: Redact stream keys from logs to prevent exposure
ngx.log(ngx.WARN, "Token stream mismatch. Expected hash: ", ngx.md5(expected_stream_key):sub(1, 8), " Got hash: ", ngx.md5(res):sub(1, 8))
return false
end
@ -81,9 +96,9 @@ function _M.refresh_viewer_token(token)
if not red then
return false
end
-- Refresh TTL to 30 seconds
local ok, err = red:expire("viewer_token:" .. token, 30)
-- Refresh TTL to 5 minutes on each HLS segment access
local ok, err = red:expire("viewer_token:" .. token, 300)
if not ok then
ngx.log(ngx.ERR, "Failed to refresh token TTL: ", err)
end

180
openresty/lua/thumbnail.lua Normal file
View file

@ -0,0 +1,180 @@
local _M = {}
local THUMB_DIR = "/tmp/thumbs"
local CACHE_TTL = 5 -- seconds before regenerating
local FFMPEG_TIMEOUT = 15 -- seconds (needs more time for animated capture)
local ANIMATION_DURATION = 3 -- seconds of video to capture
local ANIMATION_FPS = 8 -- frames per second in output
-- SECURITY FIX: Shell escape function to prevent command injection
local function shell_escape(str)
-- Escape single quotes by ending the quoted string, adding escaped quote, and starting new quoted string
return "'" .. string.gsub(str, "'", "'\\''") .. "'"
end
local function file_exists(path)
local f = io.open(path, "r")
if f then
f:close()
return true
end
return false
end
local function file_size(path)
local f = io.open(path, "rb")
if f then
local size = f:seek("end")
f:close()
return size or 0
end
return 0
end
local function file_age(path)
-- Get file modification time using stat
local handle = io.popen("stat -c %Y " .. path .. " 2>/dev/null")
if not handle then
return nil
end
local mtime = handle:read("*a")
handle:close()
if not mtime or mtime == "" then
return nil
end
local now = os.time()
return now - tonumber(mtime)
end
local function generate_thumbnail(stream_key, thumb_path)
-- Build LLHLS URL - internal docker network
local llhls_url = "http://ovenmediaengine:8080/app/" .. stream_key .. "/llhls.m3u8"
local log_file = THUMB_DIR .. "/" .. stream_key .. ".log"
-- First, try to generate animated WebP
-- Using os.execute which blocks properly until complete
-- SECURITY FIX: Use shell_escape to prevent command injection
local cmd = string.format(
"timeout %d ffmpeg -y -i %s -t %d -vf 'fps=%d,scale=320:-1:flags=lanczos' -c:v libwebp -lossless 0 -compression_level 3 -q:v 70 -loop 0 -preset default -an %s > %s 2>&1",
FFMPEG_TIMEOUT,
shell_escape(llhls_url),
ANIMATION_DURATION,
ANIMATION_FPS,
shell_escape(thumb_path),
shell_escape(log_file)
)
ngx.log(ngx.INFO, "Generating thumbnail for stream: ", stream_key)
local exit_code = os.execute(cmd)
-- Check if file was created and has content
if file_exists(thumb_path) and file_size(thumb_path) > 100 then
ngx.log(ngx.INFO, "Thumbnail generated successfully: ", thumb_path)
os.remove(log_file)
return true
end
-- If animated webp failed, try static webp (single frame)
ngx.log(ngx.WARN, "Animated webp failed (exit: ", tostring(exit_code), "), trying static")
-- SECURITY FIX: Use shell_escape to prevent command injection
local static_cmd = string.format(
"timeout %d ffmpeg -y -i %s -vframes 1 -vf 'scale=320:-1:flags=lanczos' -c:v libwebp -q:v 75 %s > %s 2>&1",
FFMPEG_TIMEOUT,
shell_escape(llhls_url),
shell_escape(thumb_path),
shell_escape(log_file)
)
exit_code = os.execute(static_cmd)
if file_exists(thumb_path) and file_size(thumb_path) > 100 then
ngx.log(ngx.INFO, "Static thumbnail generated: ", thumb_path)
os.remove(log_file)
return true
end
-- Read log file for error info
local log_content = ""
local log_f = io.open(log_file, "r")
if log_f then
log_content = log_f:read("*a") or ""
log_f:close()
end
ngx.log(ngx.ERR, "Thumbnail generation failed (exit: ", tostring(exit_code), "): ", log_content:sub(-500))
return false
end
function _M.serve()
-- Get stream key from nginx variable
local stream_key = ngx.var.stream_key
if not stream_key or stream_key == "" then
ngx.status = 400
ngx.header["Content-Type"] = "text/plain"
ngx.say("Missing stream key")
return ngx.exit(400)
end
-- Sanitize stream key (alphanumeric, dash, underscore only)
if not stream_key:match("^[%w%-_]+$") then
ngx.status = 400
ngx.header["Content-Type"] = "text/plain"
ngx.say("Invalid stream key")
return ngx.exit(400)
end
local thumb_path = THUMB_DIR .. "/" .. stream_key .. ".webp"
-- Check if cached thumbnail exists and is fresh
local age = file_age(thumb_path)
local needs_refresh = not age or age > CACHE_TTL
if needs_refresh then
-- Generate new thumbnail
local ok = generate_thumbnail(stream_key, thumb_path)
if not ok then
-- If generation failed, check if we have a stale one to serve
if not file_exists(thumb_path) or file_size(thumb_path) < 100 then
ngx.status = 503
ngx.header["Content-Type"] = "text/plain"
ngx.header["Retry-After"] = "5"
ngx.say("Thumbnail generation in progress")
return ngx.exit(503)
end
-- Serve stale thumbnail
end
end
-- Verify file exists and has content
if not file_exists(thumb_path) or file_size(thumb_path) < 100 then
ngx.status = 503
ngx.header["Content-Type"] = "text/plain"
ngx.header["Retry-After"] = "5"
ngx.say("Thumbnail not ready")
return ngx.exit(503)
end
-- Serve the thumbnail file
ngx.header["Content-Type"] = "image/webp"
ngx.header["Cache-Control"] = "public, max-age=" .. CACHE_TTL
ngx.header["Access-Control-Allow-Origin"] = "*"
local f = io.open(thumb_path, "rb")
if not f then
ngx.status = 500
ngx.header["Content-Type"] = "text/plain"
ngx.say("Failed to read thumbnail")
return ngx.exit(500)
end
local content = f:read("*a")
f:close()
ngx.print(content)
end
return _M

118
openresty/lua/uberban.lua Normal file
View file

@ -0,0 +1,118 @@
-- Site-wide uberban checking module
-- Blocks uberbanned fingerprints from accessing any endpoint
local redis = require "resty.redis"
local fingerprint = require "fingerprint"
local _M = {}
-- Cache for ban status (5 second TTL to reduce Redis load)
local uberban_cache = ngx.shared.uberban_cache
local CACHE_TTL = 5 -- seconds
-- Redis password cached at module load time
local REDIS_PASSWORD = os.getenv("REDIS_PASS")
-- Get Redis connection (same pattern as redis_helper.lua)
local function get_redis_connection()
local red = redis:new()
red:set_timeouts(1000, 1000, 1000) -- connect, send, read timeout in ms
local host = "redis"
local port = tonumber(os.getenv("REDIS_PORT")) or 6379
local ok, err = red:connect(host, port)
if not ok then
ngx.log(ngx.ERR, "[uberban] Failed to connect to Redis: ", err)
return nil
end
-- Authenticate if password is set
if REDIS_PASSWORD and REDIS_PASSWORD ~= "" then
local res, err = red:auth(REDIS_PASSWORD)
if not res then
ngx.log(ngx.ERR, "[uberban] Failed to authenticate to Redis: ", err)
return nil
end
end
-- Select database 1 (where chat-service stores fingerprint bans)
local db = tonumber(os.getenv("REDIS_DB")) or 1
local res, err = red:select(db)
if not res then
ngx.log(ngx.ERR, "[uberban] Failed to select Redis db ", db, ": ", err)
return nil
end
return red
end
local function close_redis_connection(red)
local ok, err = red:set_keepalive(10000, 100)
if not ok then
ngx.log(ngx.ERR, "[uberban] Failed to set keepalive: ", err)
end
end
-- Check if a fingerprint is uberbanned
-- Returns true if banned, false if not banned
function _M.is_banned(fp)
if not fp then
return false
end
-- Check cache first
if uberban_cache then
local cached = uberban_cache:get(fp)
if cached ~= nil then
return cached == "1"
end
end
-- Query Redis
local red = get_redis_connection()
if not red then
-- SECURITY FIX: Fail closed - if Redis is unavailable, deny access
-- This prevents banned users from accessing the system during outages
ngx.log(ngx.ERR, "[uberban] Redis unavailable - blocking request (fail closed)")
return true
end
-- Check if fingerprint is in the uberbanned set
local res, err = red:sismember("chat:banned:fingerprints", fp)
close_redis_connection(red)
if not res then
ngx.log(ngx.ERR, "[uberban] Redis SISMEMBER failed: ", err)
return false
end
local is_banned = (res == 1)
-- Cache the result
if uberban_cache then
uberban_cache:set(fp, is_banned and "1" or "0", CACHE_TTL)
end
return is_banned
end
-- Check fingerprint and block if uberbanned
-- Call this in access_by_lua_block
function _M.check_and_block()
-- Generate server-side fingerprint
local fp = fingerprint.generate()
if not fp then
return -- Can't generate fingerprint, allow request
end
-- Check if banned
if _M.is_banned(fp) then
ngx.status = ngx.HTTP_FORBIDDEN
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error": "Access denied", "banned": true}')
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
end
return _M

View file

@ -4,6 +4,12 @@ error_log stderr warn;
# Run worker processes as nobody user (master process remains root for port binding)
user nobody nogroup;
# Expose environment variables to Lua workers
env REDIS_PASS;
env REDIS_PORT;
env REDIS_HOST;
env JWT_SECRET;
events {
worker_connections 1024;
}
@ -25,51 +31,87 @@ http {
lua_package_path "/usr/local/openresty/nginx/lua/?.lua;;";
lua_shared_dict stream_keys 10m;
lua_shared_dict rate_limit 10m;
lua_shared_dict fingerprints 10m; # Server-side fingerprint cache
lua_shared_dict uberban_cache 1m; # Uberban status cache (5 second TTL)
# Enable compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss image/svg+xml;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# SECURITY FIX #17: Content Security Policy (application-wide)
# Note: 'unsafe-inline' required for style-src because Svelte uses inline styles for transitions and dynamic bindings
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.youtube.com https://s.ytimg.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: https: blob:; media-src 'self' blob: https:; frame-src https://www.youtube.com https://www.youtube-nocookie.com; connect-src 'self' wss: ws:; font-src 'self' data: https://cdnjs.cloudflare.com;" always;
# Map to handle CORS origin properly
map $http_origin $cors_origin {
default "";
"~^https?://localhost(:[0-9]+)?$" $http_origin;
"~^https?://127\.0\.0\.1(:[0-9]+)?$" $http_origin;
"~^https?://\[::1\](:[0-9]+)?$" $http_origin;
default $http_origin;
"" "";
}
# Map for WebSocket upgrade
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream backend {
server drogon-backend:8080;
}
upstream chat {
server chat-service:8081;
}
upstream frontend {
server sveltekit:3000;
}
upstream ome {
server ovenmediaengine:8080;
}
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=3r/m;
limit_req_zone $binary_remote_addr zone=register_limit:10m rate=1r/m;
limit_req_zone $binary_remote_addr zone=chat_limit:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=avatar_limit:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=nakama_limit:10m rate=30r/s;
# SECURITY FIX #11: Rate limit viewer token requests
limit_req_zone $binary_remote_addr zone=viewer_token_limit:10m rate=10r/m;
# Increase client max body size for avatar uploads
client_max_body_size 1m;
# Increase client max body size for file uploads
# Default for most uploads (images, stickers)
client_max_body_size 5m;
server {
listen 80;
server_name localhost;
# ACME challenge endpoint for Let's Encrypt certificate validation
# This must come before any access control blocks
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files $uri =404;
}
# Site-wide uberban check - blocks banned fingerprints from all endpoints
access_by_lua_block {
-- Skip OPTIONS requests (CORS preflight)
if ngx.req.get_method() == "OPTIONS" then
return
end
local uberban = require "uberban"
uberban.check_and_block()
}
# Security headers for the whole server
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
@ -77,19 +119,62 @@ http {
location /uploads/ {
# Use root directive with absolute path to avoid alias+try_files bug
root /app;
# Security settings
autoindex off;
add_header X-Content-Type-Options "nosniff" always;
# Only serve files, not directories
try_files $uri =404;
# Explicit MIME types for uploaded files
# SVG is safe when loaded via <img> tags (browsers sandbox them)
types {
image/jpeg jpg jpeg;
image/png png;
image/gif gif;
image/webp webp;
image/svg+xml svg svgz;
audio/mpeg mp3;
audio/wav wav;
audio/ogg ogg;
audio/mp4 m4a;
video/mp4 mp4 m4v;
video/webm webm;
video/quicktime mov;
}
default_type application/octet-stream;
# Security headers for all uploaded content
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'" always;
add_header X-Frame-Options "DENY" always;
# Cache static images
location ~* \.(jpg|jpeg|png|gif|webp|svg)$ {
expires 30d;
add_header Cache-Control "public, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'" always;
add_header X-Frame-Options "DENY" always;
}
# Cache audio files
location ~* \.(mp3|wav|ogg|m4a)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'" always;
add_header X-Frame-Options "DENY" always;
}
# Cache video files
location ~* \.(mp4|m4v|webm|mov)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Accept-Ranges bytes;
}
}
@ -238,27 +323,55 @@ http {
add_header Cache-Control "public, max-age=300" always;
}
# Public realm endpoints (with viewer token authentication for stream-key)
location ~ ^/api/realms/(by-name/[^/]+|live|[0-9]+/stats|[0-9]+/viewer-token|[0-9]+/stream-key)$ {
# SECURITY FIX #11: Rate-limited viewer token endpoint
location ~ ^/api/realms/[0-9]+/viewer-token$ {
limit_req zone=viewer_token_limit burst=5 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# No cache for tokens
expires -1;
add_header Cache-Control "no-store, no-cache" always;
}
# Public realm endpoints (with viewer token authentication for stream-key)
location ~ ^/api/realms/(by-name/[^/]+|live|[0-9]+/stats|[0-9]+/stream-key)$ {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Short cache for live realm data
expires 10s;
add_header Cache-Control "public, max-age=10" always;
@ -286,52 +399,387 @@ http {
proxy_set_header Cookie $http_cookie;
}
# Other API endpoints (authenticated)
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
# Chat service API endpoints
location /api/chat/ {
limit_req zone=chat_limit burst=30 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://chat;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Don't cache API responses
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# Chat WebSocket endpoints
location /chat/ws {
# Inject server-side fingerprint header (cannot be spoofed by client)
access_by_lua_block {
local fingerprint = require "fingerprint"
fingerprint.inject_header()
}
proxy_pass http://chat;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location ~ ^/chat/stream/(.+)$ {
# Inject server-side fingerprint header (cannot be spoofed by client)
access_by_lua_block {
local fingerprint = require "fingerprint"
fingerprint.inject_header()
}
proxy_pass http://chat;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Watch room sync WebSocket endpoint
location /watch/ws {
# Inject server-side fingerprint header (cannot be spoofed by client)
access_by_lua_block {
local fingerprint = require "fingerprint"
fingerprint.inject_header()
}
proxy_pass http://chat;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Ebook upload endpoint - larger body size limit
location = /api/user/ebooks {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# JWT validation at edge
access_by_lua_block {
local jwt_validator = require "jwt"
jwt_validator.validate_and_inject()
}
# Allow 100MB for ebook uploads
client_max_body_size 100m;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Extended timeout for large uploads
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# Don't cache API responses
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# Audio upload endpoint - larger body size limit
location = /api/user/audio {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# JWT validation at edge
access_by_lua_block {
local jwt_validator = require "jwt"
jwt_validator.validate_and_inject()
}
# Allow 500MB for audio uploads
client_max_body_size 500m;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Extended timeout for large uploads
proxy_read_timeout 600s;
proxy_send_timeout 600s;
# Don't cache API responses
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# Video upload endpoint - larger body size limit
location = /api/user/videos {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# JWT validation at edge
access_by_lua_block {
local jwt_validator = require "jwt"
jwt_validator.validate_and_inject()
}
# Allow 500MB for video uploads
client_max_body_size 500m;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Extended timeout for large uploads
proxy_read_timeout 600s;
proxy_send_timeout 600s;
# Don't cache API responses
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# Default avatar random endpoint - rate limited to prevent abuse
location = /api/default-avatar/random {
limit_req zone=avatar_limit burst=10 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Don't cache - ensures random avatar each time
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# Block internal API endpoints from external access (used for service-to-service communication)
location /api/internal/ {
return 403;
}
# Other API endpoints (authenticated)
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# JWT validation at edge - reject invalid tokens before hitting backend
access_by_lua_block {
local jwt_validator = require "jwt"
jwt_validator.validate_and_inject()
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Don't cache API responses
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# WebSocket
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# WebSocket timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Stream thumbnails - 3 second animated WebP generated on-demand via FFmpeg
location ~ ^/thumb/([^/]+)\.webp$ {
set $stream_key $1;
# CORS headers for preflight
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# Generate and serve thumbnail via Lua
content_by_lua_block {
local thumbnail = require "thumbnail"
thumbnail.serve()
}
}
# Nakama Game Server HTTP API (/v2/ is the default path used by nakama-js)
# Uses runtime DNS resolution via variable to avoid startup failures
location /v2/ {
limit_req zone=nakama_limit burst=50 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# Runtime DNS resolution - nakama may not be ready at nginx startup
set $nakama_backend nakama:7350;
proxy_pass http://$nakama_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Hide Nakama's CORS headers - nginx handles CORS
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Methods;
proxy_hide_header Access-Control-Allow-Headers;
proxy_hide_header Access-Control-Allow-Credentials;
expires -1;
add_header Cache-Control "no-store, no-cache" always;
}
# Nakama Game Server WebSocket (nakama-js connects to /ws with query params)
location = /ws {
# Runtime DNS resolution
set $nakama_backend nakama:7350;
# Must include $is_args$args when using variables - nginx won't auto-append query string
proxy_pass http://$nakama_backend/ws$is_args$args;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Frontend (all other requests)
location / {
proxy_pass http://frontend;
@ -339,7 +787,7 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Enable HTTP/1.1 for keep-alive
proxy_http_version 1.1;
proxy_set_header Connection "";
@ -350,7 +798,17 @@ http {
server {
listen 8088;
server_name localhost;
# Site-wide uberban check - blocks banned fingerprints from streaming
access_by_lua_block {
-- Skip OPTIONS requests (CORS preflight)
if ngx.req.get_method() == "OPTIONS" then
return
end
local uberban = require "uberban"
uberban.check_and_block()
}
# Security headers
add_header X-Content-Type-Options "nosniff" always;
@ -365,44 +823,49 @@ http {
add_header Access-Control-Allow-Headers "Range" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# Access control via Lua
# SECURITY FIX #24: Removed token value logging - sensitive data should not be in logs
access_by_lua_block {
local redis_helper = require "redis_helper"
-- Get viewer token from cookie
local cookie_header = ngx.var.http_cookie
if not cookie_header then
ngx.status = ngx.HTTP_FORBIDDEN
ngx.say("No authentication token")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- Extract viewer_token cookie
local token = nil
-- Handle URL-encoded cookies and spaces
cookie_header = ngx.unescape_uri(cookie_header)
for k, v in string.gmatch(cookie_header, "([^=]+)=([^;]+)") do
k = k:match("^%s*(.-)%s*$") -- trim whitespace
if k == "viewer_token" then
token = v:match("^%s*(.-)%s*$") -- trim whitespace
break
-- Get viewer token from query parameter or cookie
local token = ngx.var.arg_token
-- If not in query parameter, try cookie
if not token then
local cookie_header = ngx.var.http_cookie
if cookie_header then
-- Extract viewer_token cookie
-- Handle URL-encoded cookies and spaces
cookie_header = ngx.unescape_uri(cookie_header)
for k, v in string.gmatch(cookie_header, "([^=]+)=([^;]+)") do
k = k:match("^%s*(.-)%s*$") -- trim whitespace
if k == "viewer_token" then
token = v:match("^%s*(.-)%s*$") -- trim whitespace
break
end
end
end
end
if not token then
ngx.status = ngx.HTTP_FORBIDDEN
ngx.say("Missing viewer token")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- Validate token
local valid_stream = redis_helper.validate_viewer_token(token, ngx.var.stream_key)
if not valid_stream then
@ -410,25 +873,26 @@ http {
ngx.say("Invalid viewer token")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- Optionally refresh token TTL on segment access
-- Refresh token TTL on segment access
redis_helper.refresh_viewer_token(token)
}
# Cache settings for segments
location ~ \.ts$ {
expires 1h;
add_header Cache-Control "public, max-age=3600" always;
# Cache settings based on file type
# Segments (.ts, .m4s) - cache for 1 hour, playlists (.m3u8) - no cache
set $cache_control "no-cache, no-store, must-revalidate";
set $expires_time "-1";
if ($file_path ~ \.(ts|m4s)$) {
set $cache_control "public, max-age=3600";
set $expires_time "1h";
}
# Don't cache playlists
location ~ \.m3u8$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
# Proxy to OvenMediaEngine
proxy_pass http://ome/app/$stream_key/$file_path;
expires $expires_time;
add_header Cache-Control $cache_control always;
add_header X-Content-Type-Options "nosniff" always;
# Proxy to OvenMediaEngine (preserve query string for session parameter)
proxy_pass http://ome/app/$stream_key/$file_path$is_args$args;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;