Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
|
|
@ -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"]
|
||||
38
openresty/conf.d/ssl.conf.template
Normal file
38
openresty/conf.d/ssl.conf.template
Normal 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;
|
||||
}
|
||||
71
openresty/lua/fingerprint.lua
Normal file
71
openresty/lua/fingerprint.lua
Normal 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
167
openresty/lua/jwt.lua
Normal 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
|
||||
|
|
@ -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
180
openresty/lua/thumbnail.lua
Normal 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
118
openresty/lua/uberban.lua
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue