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
This commit is contained in:
Johannes Millan 2026-03-18 20:48:51 +01:00
parent 66a662aea8
commit 1907df68d7
7 changed files with 192 additions and 22 deletions

View file

@ -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/

View file

@ -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

View file

@ -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 "<no value>" from older Docker Compose versions
if [ "$HEALTH" = "<no value>" ]; 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"

View file

@ -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.',
};

View file

@ -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)}`);

View file

@ -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');
}

View file

@ -59,7 +59,7 @@ export const testRoutes = async (fastify: FastifyInstance): Promise<void> => {
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<void> => {
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)