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