Skip to content

Security: a8n-tools/rusty-links

Security

docs/SECURITY.md

Security Documentation

Comprehensive security guide for Rusty Links deployment and maintenance.

Table of Contents


Overview

Rusty Links implements security best practices for a self-hosted bookmark management application. This document outlines implemented security features, hardening procedures, and maintenance tasks.

Security Philosophy

  • Defense in Depth - Multiple layers of security
  • Least Privilege - Minimal permissions required
  • Security by Default - Secure configurations out of the box
  • Regular Updates - Timely security patches
  • Transparency - Clear vulnerability disclosure process

Implemented Security Features

Authentication & Authorization

✅ Argon2id Password Hashing

  • Industry-standard password hashing algorithm
  • Automatic salt generation
  • Configurable work factor

✅ JWT-based Authentication

  • Secure random session tokens (32 bytes / 64 hex characters)
  • Database-backed sessions (not JWT)
  • HttpOnly cookies prevent XSS attacks
  • Secure flag ensures HTTPS-only transmission
  • SameSite=Lax prevents CSRF attacks
// Cookie configuration in src/auth/session.rs
Cookie::build((SESSION_COOKIE_NAME, session_id))
    .http_only(true)   // JavaScript cannot access
    .secure(true)      // HTTPS only
    .same_site(SameSite::Lax)  // CSRF protection
    .path("/")
    .into()

✅ Single-user Mode (Phase 1)

  • Simplified attack surface
  • First user created via /api/auth/setup
  • Setup endpoint disabled after first user
  • Reduces multi-tenant security concerns

✅ New Sign-in Location Alerts (LINKS-27)

  • Every completed login records the country the edge resolved for the request
  • A login from a country the account has not used before emails the user an alert with the country, time, IP, and device
  • The country comes from the X-IPCountry header injected by the reverse proxy's geoblock plugin; there is no geoip database, and with no such header no country resolves and no alert fires
  • The header is honored only from a trusted-proxy peer (LINKS-31, TRUSTED_PROXY_CIDRS), so a client reaching the app directly can neither fake a foreign sign-in nor pin every login to one country and silence the alert
  • A first-ever login and a repeat from the same country are never flagged
  • Country-only: the LINKS-45 device signal feeds the approval gate below and deliberately does not raise an alert of its own
  • Per-user opt-out (users.notify_new_location), a global kill switch (LOGIN_LOCATION_ALERTS_ENABLED), and a cap of one alert per user per country per day
  • The opt-out is the user's to set (LINKS-33): GET /api/auth/me reports it and PATCH /api/auth/me changes it, scoped to the session's account, so a request body can never flip another user's setting. The Account page reaches it from a browser (LINKS-43), so silencing an alert no longer needs an API client
  • An absent notify_new_location key on the patch means "not submitted" and leaves the stored value alone; an explicit false persists and a non-boolean is rejected with 400
  • Runs off the login hot path, so a mail failure can never fail or slow a login
  • Alert mail leaves over an encrypted SMTP connection by default (LINKS-37): SMTP_TLS defaults to starttls (STARTTLS required, port 587) and tls selects implicit TLS (port 465)
  • Plaintext SMTP is reachable only by setting SMTP_TLS=none for a trusted loopback or sidecar MTA, and every send logs a warning naming the host, so a plaintext deployment is never silent

✅ Sign-in Approval Gate (LINKS-35, LINKS-45)

  • Opt-in (LOGIN_APPROVAL_ENABLED=true, exact match, OFF by default) because unlike the alert it can stop a real sign-in
  • Withholds a session on a sign-in that passes the password but does not look familiar: no JWT, no refresh token, and no session are issued, and the answer is 403 APPROVAL_REQUIRED
  • Two triggers, one gate, one switch. A country the account has not used before (LINKS-35) and a device it has not used before (LINKS-45). Either alone holds. A sign-in that trips both is held once and approved once, not twice. LOGIN_APPROVAL_ENABLED disables both; there is deliberately no second flag
  • The user is emailed a single-use link; opening it shows which trigger fired, the country, IP, device, and time, and a POST behind a button approves it. An attempt nobody approves simply never completes
  • Notify-and-approve, not a lock: nothing about the account is disabled, and the owner stays in control
  • NEVER gated, by construction, because gating any of these would lock a real user out:
    • a first-ever sign-in, which has no prior country and no recorded device (this is what POST /api/auth/setup creates)
    • a sign-in whose country does not resolve (no geoblock edge, or TRUSTED_PROXY_CIDRS empty), on the country trigger
    • an account with no recorded devices, on the device trigger, which is every account on the deploy that adds the table (see the backfill note below)
    • a sign-in that submits no device id at all, on the device trigger
  • The token is 256 bits of randomness and only its SHA-256 is stored, so a database dump cannot mint or replay an approval link
  • Single use: the claim is a conditional UPDATE ... WHERE consumed_at IS NULL AND expires_at > NOW(), and the affected-row count decides the race, so two concurrent clicks cannot both succeed. The link expires after 15 minutes
  • The GET deliberately does not consume the token, so a mail gateway or link scanner opening the URL cannot burn the user's only link
  • Ignores the users.notify_new_location opt-out on purpose: that preference is written from an authenticated session, so honoring it would let anyone holding a session switch the control off, and an opted-out user would be held with no mail to approve with. The opt-out governs the LINKS-27 alert only, and it disables neither trigger
  • last_login_country and known_devices are written only when a sign-in completes or an approval is claimed, so an attempt nobody approves cannot make its country or its device look familiar next time
  • At most three live links per account at a time, so a client that varies its device id on every attempt cannot mail an unbounded stream of them. The cap still holds the sign-in, it only stops another mail
  • Scope: only this service's own credential login, and the device trigger does not change that. The OIDC callback is not gated, because there the external OP made the authentication decision, owns the credential and owns recovery; a hosted deployment need not configure SMTP, so holding there would convert a control into a lockout with no way back
  • Recovery when the approval email cannot be received is below, and does not depend on email

What the device signal is, and what it is not (LINKS-45)

  • The identity is a random id the browser mints once and keeps in localStorage, sent with the sign-in request as device_id. Only its SHA-256 reaches the database (known_devices.device_id_hash), so a database dump yields nothing that can be replayed as a known device
  • The User-Agent is deliberately NOT the identity. It changes on every browser update, so a UA-derived device would hold every user after each browser release. That is a recurring lockout, and worse, it trains people to approve reflexively, which would destroy the value of the country trigger too. The UA is still recorded for the approval page to display, and nothing decides on it
  • The id travels with the request, not on a response. That is what makes approving terminate: the hold writes the submitted id's hash onto its pending row and claiming the link promotes it into known_devices. A value the server set on the approval response would mark whichever browser opened the email, usually a different one, and leave the held browser challenged forever
  • The id grants nothing. Sending one authenticates nobody and forging one gains nothing, because a value the account has no row for is exactly as unrecognised as no value at all
  • What it detects: a sign-in from a browser profile that has never completed one for this account
  • What it does NOT detect, and cannot: cleared site data, a private window, a second browser on the same machine, and a genuinely new machine are indistinguishable, because none of them carries the stored id. The failure mode is an extra approval mail, never a missed attacker
  • Not an anti-automation control. A client that sends no device_id is never held on this signal, so an API client or curl behaves exactly as it does today. This is hardening for the app's own sign-in form, not a bot gate
  • Recognition is scoped to the account, so a shared browser that one account has made known does not make a second account's first sign-in from it look familiar
  • A user lists the devices their account is recognised from, and revokes any of them, from the Account page or with GET /api/auth/devices and DELETE /api/auth/devices/{id} (LINKS-55). The list never returns device_id_hash: the response type has no field for it and the query never selects it, so the "this browser" flag is resolved by comparing hashes inside the query
  • Revocation is scoped by the same rule as the LINKS-33 write: the account is a predicate in the WHERE clause, so a row id belonging to another account matches nothing, deletes nothing, and answers the same 404 an unknown id gets
  • Revoking is never a lockout, and cannot be. It issues no session and relaxes nothing for the caller; it can only move the account toward being held more often, or back to the zero-devices baseline. Revoking the LAST device returns the account to exactly the deploy-day state below, where the device trigger holds nobody, and the next sign-in re-establishes the baseline. That is the user-facing equivalent of the operator DELETE in the recovery section
  • Revoking the browser you are signed in on does not end the session; the device is not part of the session's identity. Its next sign-in is held, which is the intended effect
  • Deliberately not password-confirmed, unlike the storefront equivalent (SF-131). Revocation grants nothing, so a stolen session gains nothing by disarming the device trigger it cannot sign in past; someone who does hold the password would satisfy the prompt anyway; and an OIDC account's password_hash is the sentinel '!sso:no-password', so a prompt would be unsatisfiable in hosted mode. The session is the authority, as it is for PATCH /api/auth/me, which silences the alert outright and is not confirmed either

Deploy day: no backfill, and nobody is held

known_devices is created empty and is deliberately not backfilled. Every account that exists when the migration runs therefore has zero known devices.

Zero known devices is read as the account's baseline, never as "new device". It is the exact analogue of a NULL users.last_login_country, and it means:

  • Nobody is held on the first sign-in after the deploy, including the operator. Reading it the other way would hold the entire user base at once, with an emailed link as the only way back in, on a deployment that may not even have SMTP configured
  • That first completed sign-in records the device, which is what establishes the baseline. From then on a different browser is genuinely new and is held
  • A first-ever sign-in is covered by the same rule rather than by a special case: a brand-new account has no recorded device either, so POST /api/auth/setup can never hold the first user out of their own instance

The predicate is a single expression (location_alert::is_new_device): a device is new only when the account HAS devices AND an id was submitted AND it matches none of them. Every never-held case above is a case where one of those three is false.

Recovering from a lost approval email

This gate can lock a user out of their own instance, so the way back in does not depend on email. In order:

  1. Turn the gate off. Set LOGIN_APPROVAL_ENABLED=false (or remove it) and restart the container. The password sign-in works again immediately. No deploy and no code change, which is the whole point of the flag being an environment variable.

  2. Clear the stored baselines, when the environment cannot be changed but the database can:

    UPDATE users SET last_login_country = NULL WHERE email = '<address>';
    DELETE FROM known_devices WHERE user_id = (SELECT id FROM users WHERE email = '<address>');

    The next sign-in then has no prior country and no recorded device, so it is treated as a first-ever sign-in and is never gated on either trigger. Both baselines are re-recorded on that sign-in. Clearing one alone is enough only if that is the trigger that fired; clearing both always works.

  3. Read the link out of the log, when SMTP is in log mode. With no SMTP_HOST/SMTP_FROM_EMAIL the approval mail is logged instead of sent, body and link included, and the app warns about exactly this at startup. Prefer path 1 or 2; a deployment turning the gate on should configure SMTP first.

There is deliberately no way to approve a held sign-in from the database: only the SHA-256 of the emailed token is stored. Recovery path 2 is the database-side way back in, and it clears the baselines rather than approving anything.

✅ Trusted Proxy Gate (LINKS-31)

  • X-Forwarded-For, X-Real-Ip, and X-IPCountry are read only when the socket peer sits in a CIDR listed in TRUSTED_PROXY_CIDRS; the peer is the one input a client cannot forge
  • Empty (the default) trusts no peer: all three headers are ignored and the socket address is used, so a direct client cannot spoof its IP or its country
  • Inside a proxy chain the rightmost X-Forwarded-For entry that is not itself a trusted proxy wins, so entries a client prepends are discarded
  • The gate runs as the outermost middleware and rewrites the request headers, so every downstream reader sees only peer-gated values
  • Unparseable CIDR entries log a warning and are skipped rather than failing boot
  • Deployments behind a reverse proxy MUST set the private ingress ranges (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fd00::/8); leaving it empty collapses every client to the proxy address and silences the location alert

✅ CSRF Protection

  • SameSite cookie attribute
  • State-changing operations use POST/PUT/DELETE
  • Stateful sessions (not vulnerable to token replay)

Input Validation

✅ URL Validation

  • Proper URL parsing with url crate
  • Domain extraction and sanitization
  • Path normalization
  • Protocol enforcement (HTTP/HTTPS only)

✅ Email Validation

  • Format checking
  • Unique constraint in database
  • Case-insensitive comparison

✅ SQL Injection Prevention

  • Parameterized queries via SQLx
  • Compile-time query verification
  • No string concatenation in queries
  • Prepared statements for all database operations
// Safe parameterized query
sqlx::query("SELECT * FROM users WHERE email = $1")
    .bind(email)
    .fetch_one(&pool)
    .await

✅ XSS Prevention

  • Content Security Policy headers (via reverse proxy)
  • Output encoding in Dioxus components
  • No raw HTML injection
  • Sanitized user input before storage

✅ Path Traversal Prevention

  • No file system access from user input
  • Assets served from fixed directories
  • No dynamic file includes

Data Protection

✅ Password Requirements

  • Minimum 8 characters (configurable)
  • Hashed with Argon2id before storage
  • Never logged or exposed in responses
  • Password hash excluded from User serialization

✅ JWT Token Security

  • Access tokens with configurable expiry
  • Refresh tokens for session renewal, stored only as their SHA-256 (refresh_tokens.token_hash, LINKS-59), so a database dump or a backup yields nothing exchangeable for a session. That matters more than it looks: a refresh is session continuation rather than a sign-in, so a replayed token never trips the approval gate below
  • A refresh rotates through one guarded DELETE ... RETURNING, so a captured token buys one session at most and two concurrent uses of it never both succeed
  • Account lockout after failed login attempts

✅ No Sensitive Data Logging

  • Passwords never logged
  • Session tokens never logged
  • Masked database URLs in logs
  • Structured logging with tracing crate
// Masked database URL for logging
pub fn masked_database_url(&self) -> String {
    // Masks password in connection string
}

✅ Database Encryption at Rest (via PostgreSQL configuration)

  • Enable with ssl=on in postgresql.conf
  • TLS for connections in production
  • Encrypted backups recommended

Network Security

✅ HTTPS Enforcement (via reverse proxy)

  • TLS 1.2+ required
  • Modern cipher suites
  • HTTP Strict Transport Security (HSTS)
  • Redirect HTTP to HTTPS

✅ CORS Configuration

  • Configured via tower-http
  • Restrictive origins in production
  • Credentials allowed for same-origin only

Dependency Security

✅ Dependency Audits (automated)

  • scripts/check-dependency-audit.nu runs cargo audit against Cargo.lock in just pre-commit and in the Dependency audit step of .forgejo/workflows/check.yml, so every push and pull request is audited before anything compiles
  • .forgejo/workflows/audit.yml runs the same guard weekly. That is the only run that sees an advisory published against a Cargo.lock nobody has touched, which a push-triggered job never catches
  • A RustSec vulnerability fails the run unless a dated exception covers it; see Security Audit below for the policy
  • No Dependabot (a GitHub feature, and this repo is Forgejo-hosted) and no Renovate configuration: nothing opens dependency-bump pull requests on its own, so cargo update is still a human action
  • Minimal dependency footprint

✅ Supply Chain Security

  • Cargo.lock committed to repository
  • Reproducible builds
  • No deprecated dependencies
  • Trusted crate sources only

Container Security

✅ Non-root User

  • Container runs as user appuser (UID 1001)
  • No privilege escalation
  • Minimal capabilities

✅ Minimal Runtime Image

  • debian:trixie-slim base
  • Only essential runtime dependencies
  • No build tools in final image
  • Multi-stage builds for size reduction

✅ Read-only Root Filesystem (optional)

  • Application doesn't write to filesystem
  • Temporary storage in /tmp if needed
  • Logs to stdout/stderr

Production Hardening Checklist

Before Deployment

Application Configuration

  • Generate strong DATABASE_URL password
  • Use long, random database password (32+ characters)
  • Set APP_PORT appropriately (usually 8080 internally)
  • Configure RUST_LOG for production (warn or error)
  • Disable debug features
  • Review .env file permissions (chmod 600)

Database Security

  • Create dedicated database user for application
  • Grant minimum required permissions
  • Enable PostgreSQL SSL/TLS
  • Configure pg_hba.conf for restricted access
  • Set strong postgres user password
  • Enable statement logging for auditing
  • Configure connection limits
  • Enable automatic backups

Docker Security

  • Use official base images
  • Scan images for vulnerabilities
  • Set resource limits (CPU, memory)
  • Use Docker secrets for sensitive data
  • Enable Docker content trust
  • Run containers with --read-only flag (if possible)
  • Limit container capabilities
  • Use user namespace remapping

Reverse Proxy

  • Configure Nginx/Caddy (see examples)
  • Enable HTTPS with valid TLS certificate
  • Set up automatic certificate renewal (Let's Encrypt)
  • Configure HSTS headers
  • Set security headers (CSP, X-Frame-Options, etc.)
  • Enable rate limiting
  • Configure request size limits
  • Set up access logging

Firewall

  • Allow only necessary ports (80, 443)
  • Block direct access to database port
  • Block direct access to application port
  • Configure fail2ban for repeated failed attempts
  • Enable UFW or iptables rules

Monitoring

  • Set up log aggregation
  • Configure alerts for errors
  • Monitor disk usage
  • Monitor CPU/memory usage
  • Set up uptime monitoring
  • Configure database query monitoring

Firewall Configuration

UFW (Ubuntu)

# Reset firewall
sudo ufw reset

# Default policies
sudo ufw default deny incoming
sudo ufw default allow outgoing

# Allow SSH (change port if needed)
sudo ufw allow 22/tcp

# Allow HTTP and HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

# Enable firewall
sudo ufw enable

# Check status
sudo ufw status verbose

iptables

# Flush existing rules
iptables -F

# Default policies
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT

# Allow loopback
iptables -A INPUT -i lo -j ACCEPT

# Allow established connections
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# Allow SSH
iptables -A INPUT -p tcp --dport 22 -j ACCEPT

# Allow HTTP and HTTPS
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT

# Save rules
iptables-save > /etc/iptables/rules.v4

Docker Security Configuration

Resource Limits

# compose.yml
services:
  app:
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 512M
        reservations:
          cpus: '0.5'
          memory: 256M

Security Options

services:
  app:
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE  # Only if binding to ports < 1024

Read-only Filesystem

services:
  app:
    read_only: true
    tmpfs:
      - /tmp
      - /var/run

Reverse Proxy Configuration

Why Use a Reverse Proxy?

  1. TLS Termination - Handle HTTPS certificates
  2. Security Headers - Add protective HTTP headers
  3. Rate Limiting - Prevent abuse
  4. Static File Serving - Offload from application
  5. Load Balancing - Distribute traffic (future)
  6. DDoS Protection - Filter malicious traffic

Nginx Configuration

See examples/nginx.conf for complete configuration.

Quick Setup:

# Install Nginx
sudo apt install nginx

# Copy configuration
sudo cp examples/nginx.conf /etc/nginx/sites-available/rustylinks
sudo ln -s /etc/nginx/sites-available/rustylinks /etc/nginx/sites-enabled/

# Test configuration
sudo nginx -t

# Reload Nginx
sudo systemctl reload nginx

Key Features:

  • HTTPS with Let's Encrypt
  • Security headers (HSTS, CSP, X-Frame-Options)
  • Rate limiting (60 requests/minute)
  • Request size limits (10MB)
  • Gzip compression
  • Access logging

Caddy Configuration

See examples/Caddyfile for complete configuration.

Quick Setup:

# Install Caddy
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

# Copy configuration
sudo cp examples/Caddyfile /etc/caddy/Caddyfile

# Reload Caddy
sudo systemctl reload caddy

Key Features:

  • Automatic HTTPS with Let's Encrypt
  • Automatic certificate renewal
  • Security headers
  • Simpler configuration than Nginx
  • Built-in rate limiting

Vulnerability Reporting

Responsible Disclosure

If you discover a security vulnerability in Rusty Links:

Do NOT:

  • ❌ Open a public issue on the tracker
  • ❌ Discuss publicly on social media
  • ❌ Exploit the vulnerability

DO:

  1. ✅ Email security report to project maintainers
  2. ✅ Provide detailed information:
    • Description of the vulnerability
    • Steps to reproduce
    • Potential impact (CVSS score if applicable)
    • Affected versions
    • Suggested fix (optional)
    • Your contact information

Response Timeline

  • Initial Response: Within 48 hours
  • Assessment: Within 1 week
  • Fix Development: 1-4 weeks (depending on severity)
  • Public Disclosure: After fix is released

Severity Levels

Level Response Time Example
Critical 24-48 hours Remote code execution, SQL injection
High 1 week Authentication bypass, XSS
Medium 2-4 weeks CSRF, information disclosure
Low As time permits Minor info leaks, UI issues

Security Advisories

Security advisories are published in the release notes at https://dev.a8n.run/a8n-tools/rusty-links/releases after fixes are released. There is no GitHub Security Advisories page; this repository is Forgejo-hosted.


Regular Maintenance

Weekly Tasks

  • Review application logs for errors
  • Check disk space usage
  • Verify backups completed successfully
  • Review failed login attempts

Monthly Tasks

  • Update dependencies (cargo update)
  • Review the warnings the weekly audit reports, and the exception rows coming up for review in scripts/check-dependency-audit.nu
  • Review access logs for anomalies
  • Test backup restoration
  • Review and rotate logs
  • Update system packages

Quarterly Tasks

  • Review and update security configurations
  • Penetration testing (if applicable)
  • Review user access (when multi-user)
  • Update TLS certificates (if not automated)
  • Review firewall rules
  • Conduct security training

Yearly Tasks

  • Comprehensive security audit
  • Review disaster recovery procedures
  • Update security documentation
  • Review and update password policy
  • Evaluate new security features
  • Review third-party integrations

Security Update Schedule

Application Updates

# Check for Rust updates
rustup update

# Update dependencies
cargo update

# Check for vulnerabilities (the same leg CI runs)
just audit

# Review changes
git log --oneline --since="1 week ago"

# Test updates
cargo test --features server --lib

# Deploy
docker compose build
docker compose up -d

System Updates

# Ubuntu/Debian
sudo apt update
sudo apt upgrade

# Reboot if kernel updated
sudo reboot

# Check running services
systemctl status rustylinks

Best Practices

Password Policy

For Administrators:

  • Minimum 16 characters
  • Mix of uppercase, lowercase, numbers, symbols
  • Unique password (not reused)
  • Use password manager
  • Rotate every 90 days
  • Enable 2FA on GitHub/email

For Application:

  • Minimum 8 characters enforced
  • Consider Argon2id work factor adjustment
  • No password reset via email (single-user)
  • Session timeout after 30 days inactivity (future feature)

Network Access

Recommended Setup:

  • Deploy behind VPN or private network
  • Use firewall to restrict access by IP
  • Do NOT expose directly to internet without reverse proxy
  • Consider using Cloudflare Tunnel or Tailscale

Cloudflare Setup:

# Install cloudflared
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared-linux-amd64.deb

# Authenticate
cloudflared tunnel login

# Create tunnel
cloudflared tunnel create rustylinks

# Configure tunnel
cloudflared tunnel route dns rustylinks links.yourdomain.com

# Run tunnel
cloudflared tunnel run rustylinks

Backup Policy

Backup Strategy:

  • Frequency: Daily automated backups
  • Retention: 30 days rolling
  • Location: Off-site storage (S3, Backblaze B2)
  • Encryption: Encrypt backups at rest
  • Testing: Test restore monthly

Backup Script:

#!/bin/bash
# /usr/local/bin/backup-rustylinks.sh

BACKUP_DIR="/backups/rustylinks"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/rustylinks_$DATE.dump"

# Create backup
docker compose exec -T postgres pg_dump -U rustylinks -Fc rustylinks > "$BACKUP_FILE"

# Encrypt backup
gpg --encrypt --recipient you@yourdomain.com "$BACKUP_FILE"
rm "$BACKUP_FILE"

# Upload to S3
aws s3 cp "$BACKUP_FILE.gpg" s3://your-bucket/rustylinks/

# Delete local encrypted backup after upload
rm "$BACKUP_FILE.gpg"

# Keep last 30 days
find $BACKUP_DIR -name "rustylinks_*.dump.gpg" -mtime +30 -delete

Update Policy

Severity-based Updates:

Severity Update Window Testing
Critical Immediate (< 24 hours) Minimal testing
High 1 week Basic testing
Medium Next maintenance window Full testing
Low Quarterly Full testing

Update Process:

  1. Read changelog and release notes
  2. Backup database and configuration
  3. Test in staging environment (if available)
  4. Apply update during low-traffic period
  5. Monitor logs for issues
  6. Rollback if problems occur

Monitoring Setup

Essential Metrics:

  • Application uptime
  • HTTP response codes (4xx, 5xx)
  • Database connection pool usage
  • Disk space usage
  • Memory usage
  • CPU usage

Recommended Tools:

  • Prometheus + Grafana - Metrics and dashboards
  • Loki - Log aggregation
  • Uptime Kuma - Uptime monitoring
  • Netdata - Real-time system monitoring

Security Audit

Running Security Audits

Rust Dependencies

The audit is a check-suite leg rather than a ritual someone has to remember. scripts/check-dependency-audit.nu wraps cargo audit and runs in three places: just pre-commit, the Dependency audit step of .forgejo/workflows/check.yml on every push and pull request, and .forgejo/workflows/audit.yml on a weekly schedule. It reads Cargo.lock, so it needs no build and finishes in seconds. cargo-audit itself is baked into the dev image; the guard installs it when it is missing.

# The leg on its own, in the dev container
just audit

# The same guard against a cargo already on PATH
nu scripts/check-dependency-audit.nu

# Prove the guard still rejects an uncovered advisory and a malformed exception
nu scripts/check-dependency-audit.nu --self-test

# The stricter view, on demand: warnings fail too
cargo audit --deny warnings

Failure policy

  • A vulnerability fails the run, naming the advisory id, the crate, the version in the lockfile and the patched range. A vulnerability is normally one cargo update --package <crate> away, so failing the run is a fix instruction rather than a toll.
  • A warning (unmaintained, unsound, yanked, notice) is printed in full and does not fail the run. The ones open at any time are typically against transitive crates with no fix this repository can take, and failing on them would block unrelated pull requests until somebody wrote an exception for work nobody can do. cargo audit --deny warnings gives the stricter view to anyone deliberately looking.
  • The push/pull-request run and the weekly run share that one policy. The schedule is not a stricter gate; it exists because a push-triggered job only ever audits a lockfile somebody just touched.

Exceptions

An advisory with no fix is suppressed only by a row in the EXCEPTIONS table in scripts/check-dependency-audit.nu. Each row names the advisory id, the crate, the LINKS issue that tracks removing it, the date the acceptance stops holding and the reason it holds until then. The guard rejects a row that is past its date, a row that covers no reported advisory, a row naming no LINKS issue and a date it cannot read, so an acceptance can neither become permanent by neglect nor linger as a dead entry. The rows in force are in the script, each pointing at its own issue; adding one means filing that issue first.

Docker Image Scanning

# Using Docker Scout
docker scout cves rusty-links:latest

# Using Trivy
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy image rusty-links:latest

# Using Snyk
snyk container test rusty-links:latest

Dependency Updates

# Install cargo-outdated
cargo install cargo-outdated

# Check outdated dependencies
cargo outdated

# Update dependencies
cargo update

# Check for unused dependencies
cargo install cargo-udeps
cargo +nightly udeps

Penetration Testing

Recommended Tests:

  1. Authentication Testing

    • Brute force protection
    • Session fixation
    • Session hijacking
    • Password policy enforcement
  2. Input Validation

    • SQL injection attempts
    • XSS attempts
    • Command injection
    • Path traversal
  3. Business Logic

    • Authorization bypass
    • Race conditions
    • Privilege escalation
  4. Infrastructure

    • TLS configuration
    • HTTP header security
    • Cookie security
    • CORS configuration

Tools:

  • OWASP ZAP
  • Burp Suite
  • Nikto
  • SQLmap (for testing, never for attack)

Security Headers

The reverse proxy should set these headers:

# Security headers (Nginx)
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;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;

Header Explanations:

  • HSTS: Forces HTTPS for 1 year
  • X-Frame-Options: Prevents clickjacking
  • X-Content-Type-Options: Prevents MIME-type sniffing
  • X-XSS-Protection: Enables browser XSS filter
  • Referrer-Policy: Controls referrer information
  • CSP: Restricts resource loading
  • Permissions-Policy: Disables unnecessary browser features

Incident Response

Security Incident Procedure

  1. Detection

    • Monitor logs for suspicious activity
    • Review failed login attempts
    • Check for unusual database queries
  2. Containment

    • Block malicious IPs
    • Disable compromised accounts
    • Isolate affected systems
  3. Investigation

    • Collect logs and evidence
    • Determine scope of breach
    • Identify attack vector
  4. Eradication

    • Remove malicious code
    • Patch vulnerabilities
    • Update credentials
  5. Recovery

    • Restore from clean backup
    • Verify system integrity
    • Monitor for re-infection
  6. Lessons Learned

    • Document incident
    • Update security procedures
    • Implement preventive measures

Compliance

Data Protection

  • GDPR (if applicable): User data is minimal (email, password hash)
  • Data Retention: Configure retention policies
  • Data Export: Supported via export functionality
  • Data Deletion: Cascade deletes on user deletion

Logging Compliance

  • PII Handling: No passwords or sensitive data in logs
  • Log Retention: Configure based on requirements
  • Access Logs: Track who accessed what data
  • Audit Trails: Database audit logging enabled

References


Support

For security-related questions:

  • Review this documentation
  • Check DEPLOYMENT.md for deployment guidance
  • See TROUBLESHOOTING.md for common issues
  • Contact maintainers for sensitive security issues

There aren't any published security advisories