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:
- MASTER_KEY (256-bit, base64url-encoded): Provided via environment variable. Used only to wrap/unwrap per-paste DEKs. Never used to encrypt content directly.
- DEK (Data Encryption Key, 256-bit): Randomly generated per paste. Encrypts the paste content.
- The DEK is wrapped (encrypted) with
MASTER_KEYusing the same XChaCha20-Poly1305 cipher. - Database stores:
encrypted_content(nonce + ciphertext) andencrypted_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:
- Client generates a random 256-bit key and encrypts content locally using XChaCha20-Poly1305.
- Server receives and stores only ciphertext (
is_e2ee = true,encrypted_dek = NULL). - The decryption key is placed in the URL fragment (e.g.,
https://paste.example.com/p/abc123#<key>). - 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_keystable. The raw key is shown once at creation time and never stored. - Prefix: The first 10 characters of the key are stored as
key_prefixfor display/identification purposes. - Validation: On each request, the provided key is hashed with SHA-256 and compared against stored hashes. The
last_used_attimestamp 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-256header assha256=<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:
| Context | Limit | Window | Key |
|---|---|---|---|
| Anonymous requests | 5 requests | 1 minute | Client IP address |
| Authenticated requests | 60 requests | 1 minute | User 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-responsetoken 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:
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:
| Header | Value |
|---|---|
X-Frame-Options | DENY |
X-Content-Type-Options | nosniff |
X-XSS-Protection | 1; mode=block |
Referrer-Policy | strict-origin-when-cross-origin |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload |
Permissions-Policy | camera=(), microphone=(), geolocation=() |
Content-Security-Policy | default-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:
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.