From 1907df68d795be02a86c5dde115fb4e6320e849e Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 18 Mar 2026 20:48:51 +0100 Subject: [PATCH] fix(sync-server): security hardening and GDPR log compliance - Remove email addresses from all log messages (~18 locations) for GDPR compliance, replacing with userId where available - Downgrade debug credential logs from info to debug level - Remove options.user from passkey registration log (contained email in JSON payload) - Add container security hardening to docker-compose.yml: - no-new-privileges on all containers - cap_drop: ALL on supersync and caddy - Memory limits (supersync 512m, postgres 1g, caddy 256m) - CPU limit on supersync (1.0) - Add .health-alert/ to gitignore - Add health-alert.sh cron script for container monitoring with OOM detection, disk checks, and email alerting --- packages/super-sync-server/.gitignore | 3 + packages/super-sync-server/docker-compose.yml | 14 ++ .../super-sync-server/scripts/health-alert.sh | 148 ++++++++++++++++++ packages/super-sync-server/src/auth.ts | 6 +- packages/super-sync-server/src/email.ts | 6 +- packages/super-sync-server/src/passkey.ts | 33 ++-- packages/super-sync-server/src/test-routes.ts | 4 +- 7 files changed, 192 insertions(+), 22 deletions(-) create mode 100755 packages/super-sync-server/scripts/health-alert.sh diff --git a/packages/super-sync-server/.gitignore b/packages/super-sync-server/.gitignore index 3f2edef595..1fc84ede87 100644 --- a/packages/super-sync-server/.gitignore +++ b/packages/super-sync-server/.gitignore @@ -7,6 +7,9 @@ data # Generated from privacy.template.html at server startup public/privacy.html +# Health alert state +.health-alert/ + # Testing artifacts .env.test backups/ diff --git a/packages/super-sync-server/docker-compose.yml b/packages/super-sync-server/docker-compose.yml index 980b6dfd96..6ca20cc7b9 100644 --- a/packages/super-sync-server/docker-compose.yml +++ b/packages/super-sync-server/docker-compose.yml @@ -56,6 +56,12 @@ services: timeout: 5s retries: 3 start_period: 10s + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + mem_limit: 512m + cpus: '1.0' networks: - internal logging: *default-logging @@ -80,6 +86,9 @@ services: timeout: 5s retries: 5 start_period: 10s + security_opt: + - no-new-privileges:true + mem_limit: 1g networks: - internal logging: *default-logging @@ -99,6 +108,11 @@ services: - DOMAIN=${DOMAIN} networks: - internal + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + mem_limit: 256m depends_on: - supersync logging: *default-logging diff --git a/packages/super-sync-server/scripts/health-alert.sh b/packages/super-sync-server/scripts/health-alert.sh new file mode 100755 index 0000000000..7b4a9ca69b --- /dev/null +++ b/packages/super-sync-server/scripts/health-alert.sh @@ -0,0 +1,148 @@ +#!/bin/bash +# SuperSync Health Alert Script +# +# Checks container health and sends an email alert if something is wrong. +# Designed to run via cron every 5 minutes. +# +# Setup: +# chmod +x scripts/health-alert.sh +# crontab -e +# */5 * * * * ALERT_EMAIL=you@example.com /path/to/super-sync-server/scripts/health-alert.sh +# +# Configuration (set these or pass via environment): +# ALERT_EMAIL - Email address to receive alerts (required) +# COMPOSE_DIR - Path to docker-compose.yml directory (default: script directory's parent) +# HEALTH_URL - Health endpoint URL (default: read from .env DOMAIN) + +# Do NOT use set -e — a monitoring script must never silently abort. +set -uo pipefail +umask 077 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_DIR="${COMPOSE_DIR:-$(dirname "$SCRIPT_DIR")}" +ALERT_EMAIL="${ALERT_EMAIL:-contact@super-productivity.com}" + +if [ ! -f "$COMPOSE_DIR/docker-compose.yml" ]; then + echo "ERROR: $COMPOSE_DIR does not contain docker-compose.yml" >&2 + exit 1 +fi + +# State file in project-local directory (not /tmp — avoids symlink attacks and tmp cleanup) +ALERT_STATE_DIR="${COMPOSE_DIR}/.health-alert" +mkdir -p "$ALERT_STATE_DIR" +ALERT_STATE_FILE="$ALERT_STATE_DIR/state" + +# Prevent concurrent runs (cron overlap if a previous run hangs) +LOCK_FILE="$ALERT_STATE_DIR/health-alert.lock" +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + exit 0 +fi + +cd "$COMPOSE_DIR" + +# Load domain from .env +DOMAIN="" +if [ -f ".env" ]; then + DOMAIN=$(grep -E '^DOMAIN=' ".env" 2>/dev/null | cut -d'=' -f2 | tr -d "\"' " || true) +fi +HEALTH_URL="${HEALTH_URL:-https://${DOMAIN:-localhost}/health}" + +PROBLEMS="" +DOCKER_OK=true + +# 0. Check Docker daemon is accessible +if ! docker info >/dev/null 2>&1; then + PROBLEMS="${PROBLEMS}Docker daemon is not running or not accessible!\n" + DOCKER_OK=false +fi + +if $DOCKER_OK; then + # 1. Check if all containers are running and healthy + SERVICES=(supersync postgres caddy) + for svc in "${SERVICES[@]}"; do + STATE=$(docker compose ps --format '{{.State}}' "$svc" 2>/dev/null || echo "missing") + HEALTH=$(docker compose ps --format '{{.Health}}' "$svc" 2>/dev/null || echo "") + # Guard against "" from older Docker Compose versions + if [ "$HEALTH" = "" ]; then HEALTH=""; fi + + if [ "$STATE" != "running" ]; then + PROBLEMS="${PROBLEMS}Container '$svc' state: ${STATE}\n" + elif [ -n "$HEALTH" ] && [ "$HEALTH" != "healthy" ]; then + PROBLEMS="${PROBLEMS}Container '$svc' health: ${HEALTH}\n" + fi + done + + # 2. Check for OOM kills via kernel log (docker OOMKilled flag resets on restart) + OOM_HITS=$(journalctl -k --since "6 minutes ago" --no-pager 2>/dev/null \ + | grep -ciE "out of memory:|oom-kill:|oom_reaper:" || true) + if [[ "$OOM_HITS" =~ ^[0-9]+$ ]] && [ "$OOM_HITS" -gt 0 ]; then + PROBLEMS="${PROBLEMS}OOM kill detected in kernel log (${OOM_HITS} entries in last 6 min)\n" + fi + + # 3. Check restart counts + # Note: RestartCount is cumulative over the container's lifetime. It only resets on + # docker compose down/up or --force-recreate. Threshold of 5 avoids false positives + # from normal deploy restarts. + for svc in "${SERVICES[@]}"; do + CONTAINER_ID=$(docker compose ps -q "$svc" 2>/dev/null | head -1 || true) + if [ -n "$CONTAINER_ID" ]; then + RESTARTS=$(docker inspect --format='{{.RestartCount}}' "$CONTAINER_ID" 2>/dev/null || echo "0") + if [[ "$RESTARTS" =~ ^[0-9]+$ ]] && [ "$RESTARTS" -gt 5 ]; then + PROBLEMS="${PROBLEMS}Container '$svc' has restarted ${RESTARTS} times\n" + fi + fi + done +fi + +# 4. Check health endpoint (runs even if Docker is down — tests from outside) +HTTP_CODE=$(curl -sf -o /dev/null -w '%{http_code}' --max-time 10 "$HEALTH_URL" 2>/dev/null || echo "000") +if [ "$HTTP_CODE" != "200" ]; then + PROBLEMS="${PROBLEMS}Health endpoint returned HTTP ${HTTP_CODE} (${HEALTH_URL})\n" +fi + +# 5. Check disk usage +for mount_point in / /var/lib/docker; do + if mountpoint -q "$mount_point" 2>/dev/null || [ "$mount_point" = "/" ]; then + DISK_USAGE=$(df --output=pcent "$mount_point" 2>/dev/null | tail -1 | tr -d ' %' || true) + if [[ "${DISK_USAGE:-0}" =~ ^[0-9]+$ ]] && [ "${DISK_USAGE:-0}" -gt 85 ]; then + PROBLEMS="${PROBLEMS}Disk usage at ${DISK_USAGE}% on ${mount_point}\n" + fi + fi +done + +# Normalize volatile data before hashing to prevent repeated alerts for the same issue +HASH_INPUT=$(printf '%s' "$PROBLEMS" | sed \ + 's/restarted [0-9]* times/restarted N times/g + s/([0-9]* entries/(N entries/g + s/at [0-9]*% on/at N% on/g + s/HTTP [0-9]*/HTTP NNN/g') +CURRENT_HASH=$(printf '%s' "$HASH_INPUT" | sha256sum | cut -d' ' -f1) +PREVIOUS_HASH=$(cat "$ALERT_STATE_FILE" 2>/dev/null || echo "none") + +if [ -n "$PROBLEMS" ]; then + if [ "$CURRENT_HASH" != "$PREVIOUS_HASH" ]; then + # New or changed problem — send alert, only write state if mail succeeds + if printf 'SuperSync health check failed at %s\n\nProblems found:\n%b\nServer: %s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$PROBLEMS" "$(hostname)" \ + | timeout 30 mail -s "SuperSync Alert: Health Check Failed" "$ALERT_EMAIL" 2>/dev/null; then + echo "$CURRENT_HASH" > "$ALERT_STATE_FILE" + else + echo "ERROR: Failed to send alert email" >&2 + fi + fi +else + # All clear — send recovery notification, only delete state if mail succeeds + if [ -f "$ALERT_STATE_FILE" ]; then + if printf 'SuperSync health check recovered at %s\n\nAll checks passing.\nServer: %s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(hostname)" \ + | timeout 30 mail -s "SuperSync OK: Health Check Recovered" "$ALERT_EMAIL" 2>/dev/null; then + rm -f "$ALERT_STATE_FILE" + else + echo "ERROR: Failed to send recovery email" >&2 + fi + fi +fi + +# Record last successful run for monitoring verification +date -u +%Y-%m-%dT%H:%M:%SZ > "$ALERT_STATE_DIR/last-run" diff --git a/packages/super-sync-server/src/auth.ts b/packages/super-sync-server/src/auth.ts index c2072f2e10..2f80e9dbbf 100644 --- a/packages/super-sync-server/src/auth.ts +++ b/packages/super-sync-server/src/auth.ts @@ -335,7 +335,7 @@ export const registerWithMagicLink = async ( }, }); - Logger.info(`Created new magic-link user for ${normalizedEmail}`); + Logger.info(`Created new magic-link user`); if (!config.testMode?.autoVerifyUsers) { // Send verification email; clean up new user on failure @@ -359,13 +359,13 @@ export const registerWithMagicLink = async ( verificationTokenExpiresAt: null, }, }); - Logger.info(`[TEST_MODE] Auto-verified magic-link user: ${normalizedEmail}`); + Logger.info(`[TEST_MODE] Auto-verified magic-link user`); return { message: 'Registration successful. Your account has been automatically verified.', }; } - Logger.info(`Magic-link registration initiated for ${normalizedEmail}`); + Logger.info(`Magic-link registration initiated`); return { message: 'Registration successful. Please check your email to verify your account.', }; diff --git a/packages/super-sync-server/src/email.ts b/packages/super-sync-server/src/email.ts index 05f3398ceb..ae835a008e 100644 --- a/packages/super-sync-server/src/email.ts +++ b/packages/super-sync-server/src/email.ts @@ -81,7 +81,7 @@ export const sendVerificationEmail = async ( `, }); - Logger.info(`Verification email sent to ${to}: ${info.messageId}`); + Logger.info(`Verification email sent: ${info.messageId}`); // If using Ethereal, log the preview URL if (nodemailer.getTestMessageUrl(info)) { @@ -138,7 +138,7 @@ export const sendPasskeyRecoveryEmail = async ( `, }); - Logger.info(`Passkey recovery email sent to ${to}: ${info.messageId}`); + Logger.info(`Passkey recovery email sent: ${info.messageId}`); // If using Ethereal, log the preview URL if (nodemailer.getTestMessageUrl(info)) { @@ -195,7 +195,7 @@ export const sendLoginMagicLinkEmail = async ( `, }); - Logger.info(`Magic link login email sent to ${to}: ${info.messageId}`); + Logger.info(`Magic link login email sent: ${info.messageId}`); if (nodemailer.getTestMessageUrl(info)) { Logger.info(`Preview URL: ${nodemailer.getTestMessageUrl(info)}`); diff --git a/packages/super-sync-server/src/passkey.ts b/packages/super-sync-server/src/passkey.ts index 535dfaa989..99b6c16f33 100644 --- a/packages/super-sync-server/src/passkey.ts +++ b/packages/super-sync-server/src/passkey.ts @@ -116,9 +116,8 @@ export const generateRegistrationOptions = async ( storeChallenge(email, options.challenge); Logger.info( - `Registration options for ${email}: ${JSON.stringify({ + `Registration options generated: ${JSON.stringify({ rp: options.rp, - user: options.user, pubKeyCredParams: options.pubKeyCredParams, authenticatorSelection: options.authenticatorSelection, attestation: options.attestation, @@ -152,7 +151,7 @@ export const verifyRegistration = async ( requireUserVerification: false, // We use 'preferred', not 'required' }); } catch (err) { - Logger.warn(`Passkey registration verification failed for ${email}: ${err}`); + Logger.warn(`Passkey registration verification failed: ${err}`); throw new Error('Passkey verification failed. Please try again.'); } @@ -166,9 +165,9 @@ export const verifyRegistration = async ( // We need to decode it to get the actual raw credential ID bytes const credentialIdBase64url = Buffer.from(credentialInfo.id).toString('utf-8'); const credentialIdRawBytes = Buffer.from(credentialIdBase64url, 'base64url'); - Logger.info(`[DEBUG] Registration credentialId base64url: ${credentialIdBase64url}`); - Logger.info( - `[DEBUG] Registration credentialId raw bytes (hex): ${credentialIdRawBytes.toString('hex')}`, + Logger.debug(`Registration credentialId base64url: ${credentialIdBase64url}`); + Logger.debug( + `Registration credentialId raw bytes (hex): ${credentialIdRawBytes.toString('hex')}`, ); const verificationToken = randomBytes(32).toString('hex'); @@ -244,7 +243,7 @@ export const verifyRegistration = async ( }, }); - Logger.info(`Created new passkey user for ${email}`); + Logger.info(`Created new passkey user`); } // In TEST_MODE with autoVerifyUsers, skip email and auto-verify @@ -258,7 +257,7 @@ export const verifyRegistration = async ( verificationTokenExpiresAt: null, }, }); - Logger.info(`[TEST_MODE] Auto-verified passkey user: ${email}`); + Logger.info(`[TEST_MODE] Auto-verified passkey user`); return { message: 'Registration successful. Your account has been automatically verified.', }; @@ -275,12 +274,12 @@ export const verifyRegistration = async ( }); if (user && user.isVerified === 0) { await prisma.user.delete({ where: { id: user.id } }); - Logger.info(`Cleaned up failed passkey registration for ${email}`); + Logger.info(`Cleaned up failed passkey registration (ID: ${user.id})`); } throw new Error('Failed to send verification email. Please try again later.'); } - Logger.info(`Passkey registration initiated for ${email}`); + Logger.info(`Passkey registration initiated`); return { message: 'Registration successful. Please check your email to verify your account.', }; @@ -317,7 +316,9 @@ export const generateAuthenticationOptions = async ( // Don't provide allowCredentials - let browser discover resident credentials // This works because we use residentKey: 'required' during registration - Logger.info(`Login for ${email}: using discoverable credentials (no allowCredentials)`); + Logger.info( + `Login (userId: ${user.id}): using discoverable credentials (no allowCredentials)`, + ); const options = await webAuthnGenerateAuthentication({ rpID, @@ -328,7 +329,7 @@ export const generateAuthenticationOptions = async ( storeChallenge(email, options.challenge); Logger.info( - `Generated passkey authentication options for ${email}: rpId=${options.rpId}, discoverable=true`, + `Generated passkey authentication options (userId: ${user.id}): rpId=${options.rpId}, discoverable=true`, ); return options; }; @@ -371,7 +372,9 @@ export const verifyAuthentication = async ( // Log if the email doesn't match (user selected a different account's passkey) if (user.email.toLowerCase() !== email.toLowerCase()) { - Logger.info(`User authenticated with passkey for ${user.email} but entered ${email}`); + Logger.info( + `User authenticated with passkey for a different account (userId: ${user.id})`, + ); } let verification; @@ -390,7 +393,9 @@ export const verifyAuthentication = async ( }, }); } catch (err) { - Logger.warn(`Passkey authentication verification failed for ${email}: ${err}`); + Logger.warn( + `Passkey authentication verification failed (userId: ${user.id}): ${err}`, + ); throw new Error('Invalid credentials'); } diff --git a/packages/super-sync-server/src/test-routes.ts b/packages/super-sync-server/src/test-routes.ts index f3866120cd..810c74e17b 100644 --- a/packages/super-sync-server/src/test-routes.ts +++ b/packages/super-sync-server/src/test-routes.ts @@ -59,7 +59,7 @@ export const testRoutes = async (fastify: FastifyInstance): Promise => { userId = existingUser.id; tokenVersion = existingUser.tokenVersion ?? 0; Logger.info( - `[TEST] Returning existing user: ${email} (ID: ${userId}) - Clearing old data`, + `[TEST] Returning existing user (ID: ${userId}) - Clearing old data`, ); // Clear old data for this user to ensure clean state. @@ -85,7 +85,7 @@ export const testRoutes = async (fastify: FastifyInstance): Promise => { userId = user.id; tokenVersion = 0; - Logger.info(`[TEST] Created test user: ${email} (ID: ${userId})`); + Logger.info(`[TEST] Created test user (ID: ${userId})`); } // Generate JWT token (include tokenVersion for consistency with auth.ts)