mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
66a662aea8
commit
1907df68d7
7 changed files with 192 additions and 22 deletions
3
packages/super-sync-server/.gitignore
vendored
3
packages/super-sync-server/.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
148
packages/super-sync-server/scripts/health-alert.sh
Executable file
148
packages/super-sync-server/scripts/health-alert.sh
Executable 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"
|
||||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue