Skip to content

Security

Encryption at Rest

All paste content is encrypted before being written to the database. There is no plaintext storage path.

Cipher: XChaCha20-Poly1305 (AEAD)

  • Algorithm: XChaCha20-Poly1305 (IETF variant) via libsodium
  • Key size: 256 bits
  • Nonce size: 192 bits (24 bytes), randomly generated per encryption operation
  • Authentication: Built-in Poly1305 MAC provides authenticated encryption with associated data (AEAD)
  • Encoding: Ciphertext stored as base64url(nonce || ciphertext) (no padding)

The extended 192-bit nonce eliminates realistic nonce collision risk with random generation, unlike standard ChaCha20-Poly1305 (96-bit nonce) which requires careful nonce management.

Mode A -- Envelope Encryption

Server-side encryption using a two-layer key hierarchy:

  1. MASTER_KEY (256-bit, base64url-encoded): Provided via environment variable. Used only to wrap/unwrap per-paste DEKs. Never used to encrypt content directly.
  2. DEK (Data Encryption Key, 256-bit): Randomly generated per paste. Encrypts the paste content.
  3. The DEK is wrapped (encrypted) with MASTER_KEY using the same XChaCha20-Poly1305 cipher.
  4. Database stores: encrypted_content (nonce + ciphertext) and encrypted_dek (nonce + wrapped DEK), both base64url-encoded.

Key rotation: Changing MASTER_KEY requires re-wrapping all existing DEKs. The content itself does not need re-encryption since each paste's DEK remains the same.

Threat model: Protects content if the database is compromised. An attacker with database access but without MASTER_KEY cannot decrypt any paste content.

Mode B -- End-to-End Encryption (E2EE)

Client-side encryption where the server is a blind store:

  1. Client generates a random 256-bit key and encrypts content locally using XChaCha20-Poly1305.
  2. Server receives and stores only ciphertext (is_e2ee = true, encrypted_dek = NULL).
  3. The decryption key is placed in the URL fragment (e.g., https://paste.example.com/p/abc123#<key>).
  4. URL fragments are never sent to the server by browsers per RFC 3986.

Threat model: Protects content from the server operator. Even with both database access and MASTER_KEY, E2EE pastes cannot be decrypted without the key from the URL fragment.

API Key Security

  • Format: pb_ prefix + 32 random bytes encoded as base64url (total ~46 characters)
  • Storage: Only the SHA-256 hash of the full key is stored in the api_keys table. The raw key is shown once at creation time and never stored.
  • Prefix: The first 10 characters of the key are stored as key_prefix for display/identification purposes.
  • Validation: On each request, the provided key is hashed with SHA-256 and compared against stored hashes. The last_used_at timestamp is updated on successful validation.
  • Revocation: Keys can be deactivated by setting is_active = false. Deactivated keys are rejected immediately.

Webhook Signing

Webhook payloads are signed with HMAC-SHA256 to allow receivers to verify authenticity:

  • Each webhook endpoint has a unique 32-byte random secret (hex-encoded, 64 characters) generated at creation time.
  • The signature is sent in the X-Pastebox-Signature-256 header as sha256=<hex-digest>.
  • Signature verification uses timing-safe comparison (crypto.timingSafeEqual) to prevent timing attacks.
  • Additional headers: X-Pastebox-Event (event type), X-Pastebox-Delivery (delivery ID).

Rate Limiting

Rate limits are enforced by @fastify/rate-limit:

ContextLimitWindowKey
Anonymous requests5 requests1 minuteClient IP address
Authenticated requests60 requests1 minuteUser ID

Defaults can be configured via RATE_LIMIT_ANON and RATE_LIMIT_AUTH environment variables.

CAPTCHA (Cloudflare Turnstile)

When TURNSTILE_ENABLED=true:

  • Anonymous paste creation requests must include a cf-turnstile-response token in the request body.
  • The token is verified server-side against the Cloudflare Turnstile API.
  • Authenticated requests (with a valid API key) bypass the CAPTCHA check.

Admin Authentication

The admin panel (/admin/*) has no application-level authentication. Access control is handled entirely at the Nginx layer using HTTP Basic Auth:

nginx
location /admin {
    auth_basic "Pastebox Admin";
    auth_basic_user_file /etc/nginx/htpasswd/pastebox;
    proxy_pass http://pastebox_api;
}

The htpasswd file is managed via Ansible vault variables (vault_htpasswd_user, vault_htpasswd_password).

Important: If running without Nginx (e.g., in development), the admin panel is publicly accessible. Do not expose the application directly to the internet without the Nginx proxy.

Security Headers

Set by Nginx on all HTTPS responses:

HeaderValue
X-Frame-OptionsDENY
X-Content-Type-Optionsnosniff
X-XSS-Protection1; mode=block
Referrer-Policystrict-origin-when-cross-origin
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preload
Permissions-Policycamera=(), microphone=(), geolocation=()
Content-Security-Policydefault-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; connect-src 'self'; img-src 'self' data:; font-src 'self'; frame-src https://challenges.cloudflare.com;

Log Redaction

Fastify's Pino logger is configured to redact sensitive headers from request logs:

js
redact: ['req.headers.authorization', 'req.headers["x-api-key"]']

This prevents API keys and authorization tokens from appearing in log files.

Public ID Entropy

Each paste is assigned a public ID generated from 24 random bytes encoded as base64url, providing approximately 192 bits of entropy. This makes brute-force enumeration of paste IDs computationally infeasible.

TLS

  • Nginx terminates TLS with certificates from Let's Encrypt (certbot).
  • Only TLS 1.2 and 1.3 are permitted (ssl_protocols TLSv1.2 TLSv1.3).
  • Cipher suite restricted to HIGH:!aNULL:!MD5.
  • HSTS enabled with 2-year max-age, includeSubDomains, and preload.

Self-hosted paste service with E2EE