# FlaskPaste API Reference ## Overview FlaskPaste provides a RESTful API for creating, retrieving, and deleting text and binary pastes. **Base URL:** `http://your-server:5000/` **Content Types:** - Requests: `application/json`, `text/plain`, `application/octet-stream`, or any binary type - Responses: `application/json` for metadata, original MIME type for raw content ## Authentication Authentication is optional and uses client certificate fingerprints passed via the `X-SSL-Client-SHA1` header. ```http X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ``` The fingerprint must be exactly 40 lowercase hexadecimal characters (SHA1). **Benefits of authentication:** - Larger upload limit (50 MiB vs 3 MiB) - Ability to delete owned pastes --- ## Endpoints ### GET /challenge Get a proof-of-work challenge for paste creation. Required when PoW is enabled. **Request:** ```http GET /challenge HTTP/1.1 Host: localhost:5000 ``` **Response (PoW disabled):** ```json { "enabled": false, "difficulty": 0 } ``` **Response (PoW enabled):** ```json { "enabled": true, "nonce": "a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8", "difficulty": 20, "expires": 1700000300, "token": "a1b2c3d4...:1700000300:20:signature" } ``` The client must find a number `N` such that `SHA256(nonce + ":" + N)` has at least `difficulty` leading zero bits. --- ### GET /health Health check endpoint for load balancers and monitoring. **Request:** ```http GET /health HTTP/1.1 Host: localhost:5000 ``` **Response (200 OK):** ```json { "status": "healthy", "database": "ok" } ``` **Response (503 Service Unavailable):** ```json { "status": "unhealthy", "database": "error" } ``` --- ### GET / Returns API information and usage instructions. **Request:** ```http GET / HTTP/1.1 Host: localhost:5000 ``` **Response:** ```json { "name": "FlaskPaste", "version": "1.2.0", "endpoints": { "GET /": "API information", "GET /health": "Health check", "POST /": "Create paste", "GET /": "Retrieve paste metadata", "GET //raw": "Retrieve raw paste content", "DELETE /": "Delete paste" }, "usage": { "raw": "curl --data-binary @file.txt http://host/", "pipe": "cat file.txt | curl --data-binary @- http://host/", "json": "curl -H 'Content-Type: application/json' -d '{\"content\":\"...\"}' http://host/" }, "note": "Use --data-binary (not -d) to preserve newlines" } ``` --- ### POST / Create a new paste. **Request (Raw Binary):** ```http POST / HTTP/1.1 Host: localhost:5000 Content-Type: application/octet-stream ``` **Request (JSON):** ```http POST / HTTP/1.1 Host: localhost:5000 Content-Type: application/json {"content": "Hello, World!"} ``` **Request (with Proof-of-Work):** When PoW is enabled, include the challenge token and solution: ```http POST / HTTP/1.1 Host: localhost:5000 Content-Type: text/plain X-PoW-Token: a1b2c3d4...:1700000300:20:signature X-PoW-Solution: 12345678 Hello, World! ``` **Request (Burn-After-Read):** Create a paste that deletes itself after first retrieval: ```http POST / HTTP/1.1 Host: localhost:5000 Content-Type: text/plain X-Burn-After-Read: true Secret message ``` **Request (Custom Expiry):** Create a paste with custom expiry time (in seconds): ```http POST / HTTP/1.1 Host: localhost:5000 Content-Type: text/plain X-Expiry: 3600 Expires in 1 hour ``` **Request (Password Protected):** Create a paste that requires a password to access: ```http POST / HTTP/1.1 Host: localhost:5000 Content-Type: text/plain X-Paste-Password: secretpassword Password protected content ``` **Response (201 Created):** ```json { "id": "abc12345", "url": "/abc12345", "raw": "/abc12345/raw", "mime_type": "text/plain", "created_at": 1700000000, "owner": "a1b2c3...", // Only present if authenticated "burn_after_read": true, // Only present if enabled "expires_at": 1700003600, // Only present if custom expiry set "password_protected": true // Only present if password set } ``` **Errors:** | Code | Description | |------|-------------| | 400 | No content provided | | 400 | Password too long (max 1024 chars) | | 400 | Proof-of-work required (when PoW enabled) | | 400 | Proof-of-work failed (invalid/expired challenge) | | 413 | Paste too large | | 429 | Duplicate content rate limit exceeded | **Size Limits:** - Anonymous: 3 MiB (configurable via `FLASKPASTE_MAX_ANON`) - Authenticated: 50 MiB (configurable via `FLASKPASTE_MAX_AUTH`) --- ### GET /{id} ### HEAD /{id} Retrieve paste metadata. HEAD returns headers only (no body). **Request:** ```http GET /abc12345 HTTP/1.1 Host: localhost:5000 ``` **Request (Password Protected):** ```http GET /abc12345 HTTP/1.1 Host: localhost:5000 X-Paste-Password: secretpassword ``` **Response (200 OK):** ```json { "id": "abc12345", "mime_type": "text/plain", "size": 1234, "created_at": 1700000000, "raw": "/abc12345/raw", "password_protected": true // Only present if protected } ``` **Errors:** | Code | Description | |------|-------------| | 400 | Invalid paste ID format | | 401 | Password required | | 403 | Invalid password | | 404 | Paste not found | --- ### GET /{id}/raw ### HEAD /{id}/raw Retrieve raw paste content with correct MIME type. HEAD returns headers only (useful for checking MIME type and size without downloading content). **Request:** ```http GET /abc12345/raw HTTP/1.1 Host: localhost:5000 ``` **Request (Password Protected):** ```http GET /abc12345/raw HTTP/1.1 Host: localhost:5000 X-Paste-Password: secretpassword ``` **Response (200 OK):** ```http HTTP/1.1 200 OK Content-Type: image/png Content-Disposition: inline ``` - `Content-Disposition: inline` is set for `image/*` and `text/*` types - Content-Type matches the detected/stored MIME type **Errors:** | Code | Description | |------|-------------| | 400 | Invalid paste ID format | | 401 | Password required | | 403 | Invalid password | | 404 | Paste not found | --- ### DELETE /{id} Delete a paste. Requires authentication and ownership. **Request:** ```http DELETE /abc12345 HTTP/1.1 Host: localhost:5000 X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ``` **Response (200 OK):** ```json { "message": "Paste deleted" } ``` **Errors:** | Code | Description | |------|-------------| | 400 | Invalid paste ID format | | 401 | Authentication required | | 403 | Permission denied (not owner) | | 404 | Paste not found | --- ## MIME Type Detection FlaskPaste automatically detects MIME types using: 1. **Magic byte signatures** (highest priority) - PNG: `\x89PNG\r\n\x1a\n` - JPEG: `\xff\xd8\xff` - GIF: `GIF87a` or `GIF89a` - WebP: `RIFF....WEBP` - ZIP: `PK\x03\x04` - PDF: `%PDF` - GZIP: `\x1f\x8b` 2. **Explicit Content-Type header** (if not generic) 3. **UTF-8 detection** (falls back to `text/plain`) 4. **Binary fallback** (`application/octet-stream`) --- ## Paste Expiry Pastes expire based on last access time (default: 5 days). - Every `GET /{id}` or `GET /{id}/raw` updates the last access timestamp - Cleanup runs automatically (hourly, throttled) - Configurable via `FLASKPASTE_EXPIRY` environment variable **Custom Expiry:** Pastes can have custom expiry times using the `X-Expiry` header: ```bash # Paste expires in 1 hour curl -H "X-Expiry: 3600" --data-binary @file.txt http://host/ ``` **Configuration:** ```bash export FLASKPASTE_EXPIRY=432000 # Default expiry: 5 days export FLASKPASTE_MAX_EXPIRY=2592000 # Max custom expiry: 30 days ``` **Notes:** - Custom expiry is capped at `FLASKPASTE_MAX_EXPIRY` - Invalid or negative values are ignored (uses default) - Response includes `expires_at` timestamp when custom expiry is set --- ## Burn-After-Read Single-access pastes that delete themselves after first retrieval. **How it works:** - Set `X-Burn-After-Read: true` header on creation - First `GET /{id}/raw` returns content and deletes paste - Subsequent requests return 404 - Metadata `GET /{id}` does not trigger burn - `HEAD` requests do not trigger burn **Usage:** ```bash # Create burn-after-read paste curl -H "X-Burn-After-Read: true" --data-binary @secret.txt http://host/ # Response indicates burn is enabled { "id": "abc12345", "burn_after_read": true, ... } ``` **Notes:** - Response includes `X-Burn-After-Read: true` header when content is retrieved - Can be combined with custom expiry (paste expires OR burns, whichever first) - Accepted values: `true`, `1`, `yes` (case-insensitive) --- ## Password Protection Pastes can be protected with a password using PBKDF2-HMAC-SHA256 hashing. **Creating a protected paste:** ```http POST / HTTP/1.1 Host: localhost:5000 Content-Type: text/plain X-Paste-Password: mysecretpassword Protected content here ``` **Response (201 Created):** ```json { "id": "abc12345", "url": "/abc12345", "raw": "/abc12345/raw", "mime_type": "text/plain", "created_at": 1700000000, "password_protected": true } ``` **Accessing protected paste:** ```http GET /abc12345 HTTP/1.1 Host: localhost:5000 X-Paste-Password: mysecretpassword ``` **Errors:** | Code | Description | |------|-------------| | 400 | Password too long (max 1024 chars) | | 401 | Password required | | 403 | Invalid password | **Response (401 Unauthorized):** ```json { "error": "Password required", "password_protected": true } ``` **Response (403 Forbidden):** ```json { "error": "Invalid password" } ``` **Security Implementation:** - PBKDF2-HMAC-SHA256 with 600,000 iterations (OWASP 2023) - 32-byte random salt per password - Constant-time comparison prevents timing attacks - Passwords never logged or stored in plaintext - Maximum 1024 characters to prevent DoS via expensive hashing **CLI Usage:** ```bash # Create protected paste ./fpaste create -p "mypassword" secret.txt # Retrieve protected paste ./fpaste get -p "mypassword" abc12345 ``` **Notes:** - Password protection can be combined with burn-after-read and custom expiry - Unicode passwords are supported - Special characters are allowed --- ## Abuse Prevention FlaskPaste includes content-hash based deduplication to prevent spam and abuse. **How it works:** - Each paste's SHA256 content hash is tracked - Repeated submissions of identical content are throttled - After exceeding the threshold, further duplicates are rejected with 429 **Default limits:** - Window: 1 hour (`FLASKPASTE_DEDUP_WINDOW`) - Maximum: 3 identical submissions per window (`FLASKPASTE_DEDUP_MAX`) **Response (429 Too Many Requests):** ```json { "error": "Duplicate content rate limit exceeded", "count": 3, "window_seconds": 3600 } ``` **Configuration:** ```bash export FLASKPASTE_DEDUP_WINDOW=3600 # Window in seconds (default: 1 hour) export FLASKPASTE_DEDUP_MAX=3 # Max duplicates per window (default: 3) ``` **Notes:** - Different content is not affected by other content's limits - Counter resets after the window expires - Hash records are cleaned up periodically --- ## Entropy Enforcement FlaskPaste can require minimum content entropy to enforce client-side encryption. **How it works:** - Shannon entropy is calculated for submitted content (bits per byte) - Encrypted/random data: ~7.5-8.0 bits/byte - English text: ~4.0-5.0 bits/byte - Content below threshold is rejected with 400 **Configuration:** ```bash export FLASKPASTE_MIN_ENTROPY=6.0 # Require encryption-level entropy (0=disabled) export FLASKPASTE_MIN_ENTROPY_SIZE=256 # Only check content >= this size (default: 256) ``` **Response (400 Bad Request):** ```json { "error": "Content entropy too low", "entropy": 4.12, "min_entropy": 7.0, "hint": "Encrypt content before uploading (-e flag in fpaste)" } ``` **Caveats:** - Small data is exempt (configurable via `MIN_ENTROPY_SIZE`, default 256 bytes) - Compressed data (gzip, zip) also has high entropy — not distinguishable from encrypted - This is a heuristic, not cryptographic proof of encryption **Recommended thresholds:** | Threshold | Effect | |-----------|--------| | 0 | Disabled (default) | | 5.0 | Blocks most plaintext | | 6.0 | Requires encryption or compression | | 7.0 | Requires encryption + sufficient size | --- ## Proof-of-Work FlaskPaste includes an optional proof-of-work system to prevent automated spam. **How it works:** 1. Client requests a challenge via `GET /challenge` 2. Server returns a nonce, difficulty, expiry time, and signed token 3. Client computes SHA256 hashes until finding one with enough leading zero bits 4. Client submits paste with `X-PoW-Token` and `X-PoW-Solution` headers **Algorithm:** ``` For N = 0, 1, 2, ...: hash = SHA256(nonce + ":" + N) if leading_zero_bits(hash) >= difficulty: return N as solution ``` **Configuration:** ```bash export FLASKPASTE_POW_DIFFICULTY=20 # Leading zero bits required (0=disabled) export FLASKPASTE_POW_TTL=300 # Challenge validity in seconds export FLASKPASTE_POW_SECRET="..." # Optional signing key (auto-generated if empty) ``` **Headers:** | Header | Description | |--------|-------------| | `X-PoW-Token` | The full token from `/challenge` response | | `X-PoW-Solution` | The computed solution number | **Error responses:** ```json { "error": "Proof-of-work required", "hint": "GET /challenge for a new challenge" } ``` ```json { "error": "Proof-of-work failed: Challenge expired" } ``` **Notes:** - Difficulty 20 requires approximately 1 million hash attempts on average - Challenges are signed to prevent tampering - Each challenge can only be used once (nonce uniqueness) --- ## Error Response Format All errors return JSON: ```json { "error": "Description of the error" } ``` For size limit errors (413): ```json { "error": "Paste too large", "size": 5000000, "max_size": 3145728, "authenticated": false } ``` --- ## Security Headers All responses include the following security headers: | Header | Value | |--------|-------| | `X-Content-Type-Options` | `nosniff` | | `X-Frame-Options` | `DENY` | | `X-XSS-Protection` | `1; mode=block` | | `Referrer-Policy` | `strict-origin-when-cross-origin` | | `Content-Security-Policy` | `default-src 'none'; frame-ancestors 'none'` | | `Permissions-Policy` | `geolocation=(), microphone=(), camera=()` | | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | | `Cache-Control` | `no-store, no-cache, must-revalidate, private` | | `Pragma` | `no-cache` | --- ## Request Tracing All requests include an `X-Request-ID` header for log correlation: - If the client provides `X-Request-ID`, it is passed through - If not provided, a UUID is generated - The ID is echoed back in the response - All log entries include `[rid=]` **Example:** ```bash # Client-provided ID curl -H "X-Request-ID: my-trace-123" https://paste.example.com/health # Response includes: # X-Request-ID: my-trace-123 ``` --- ## Proxy Trust Validation When `FLASKPASTE_PROXY_SECRET` is configured, the application validates that requests come from a trusted reverse proxy by checking the `X-Proxy-Secret` header. This provides defense-in-depth against header spoofing if an attacker bypasses the reverse proxy. **Configuration:** ```bash export FLASKPASTE_PROXY_SECRET="your-secret-value" ``` **Proxy Configuration (HAProxy):** ``` http-request set-header X-Proxy-Secret your-secret-value ``` If the secret doesn't match, authentication headers (`X-SSL-Client-SHA1`) are ignored and the request is treated as anonymous. --- ## PKI (Certificate Authority) FlaskPaste includes an optional minimal PKI for issuing client certificates. ### Configuration Enable PKI via environment variables: ```bash export FLASKPASTE_PKI_ENABLED=1 # Enable PKI endpoints export FLASKPASTE_PKI_CA_PASSWORD="secret" # Required: CA password export FLASKPASTE_PKI_CERT_DAYS=365 # Client certificate validity (days) export FLASKPASTE_PKI_CA_DAYS=3650 # CA certificate validity (days) ``` ### Certificate Revocation When PKI is enabled, certificates issued by the CA are tracked. Revoked certificates are rejected during authentication (treated as anonymous). --- ### GET /pki Get PKI status and CA information. **Request:** ```http GET /pki HTTP/1.1 Host: localhost:5000 ``` **Response (PKI disabled):** ```json { "enabled": false } ``` **Response (PKI enabled, no CA):** ```json { "enabled": true, "ca_exists": false, "hint": "POST /pki/ca to generate CA" } ``` **Response (PKI enabled with CA):** ```json { "enabled": true, "ca_exists": true, "common_name": "FlaskPaste CA", "fingerprint_sha1": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "created_at": 1700000000, "expires_at": 2015000000, "key_algorithm": "ec:secp384r1" } ``` --- ### POST /pki/ca Generate a new Certificate Authority. Only works once (first-run bootstrap). **Request:** ```http POST /pki/ca HTTP/1.1 Host: localhost:5000 Content-Type: application/json {"common_name": "My CA"} ``` **Response (201 Created):** ```json { "message": "CA generated", "common_name": "My CA", "fingerprint_sha1": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "created_at": 1700000000, "expires_at": 2015000000, "download": "/pki/ca.crt" } ``` **Errors:** | Code | Description | |------|-------------| | 404 | PKI not enabled | | 409 | CA already exists | | 500 | PKI_CA_PASSWORD not configured | --- ### GET /pki/ca.crt Download CA certificate in PEM format (for trust store). **Request:** ```http GET /pki/ca.crt HTTP/1.1 Host: localhost:5000 ``` **Response (200 OK):** ``` -----BEGIN CERTIFICATE----- MIICxDCCAaygAwIBAgIUY... -----END CERTIFICATE----- ``` Content-Type: `application/x-pem-file` **Errors:** | Code | Description | |------|-------------| | 404 | PKI not enabled or CA not initialized | --- ### POST /pki/issue Issue a new client certificate (open registration). **Request:** ```http POST /pki/issue HTTP/1.1 Host: localhost:5000 Content-Type: application/json {"common_name": "alice"} ``` **Response (201 Created):** ```json { "message": "Certificate issued", "serial": "00000000000000000000000000000001", "common_name": "alice", "fingerprint_sha1": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "created_at": 1700000000, "expires_at": 1731536000, "certificate_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n", "private_key_pem": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" } ``` **Errors:** | Code | Description | |------|-------------| | 400 | common_name required | | 404 | PKI not enabled or CA not initialized | | 500 | Certificate issuance failed | **Security Notes:** - Private key is generated server-side and returned in response - Store the private key securely; it is not recoverable - The certificate can be used with nginx, HAProxy, or curl for mTLS --- ### GET /pki/certs List certificates (own certificates only). **Request (authenticated):** ```http GET /pki/certs HTTP/1.1 Host: localhost:5000 X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ``` **Response (200 OK):** ```json { "certificates": [ { "serial": "00000000000000000000000000000001", "common_name": "alice", "fingerprint_sha1": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "created_at": 1700000000, "expires_at": 1731536000, "status": "valid", "issued_to": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" } ], "count": 1 } ``` **Notes:** - Anonymous users receive an empty list - Users see certificates they issued or certificates that match their fingerprint --- ### POST /pki/revoke/{serial} Revoke a certificate by serial number. Requires authentication and ownership. **Request:** ```http POST /pki/revoke/00000000000000000000000000000001 HTTP/1.1 Host: localhost:5000 X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ``` **Response (200 OK):** ```json { "message": "Certificate revoked", "serial": "00000000000000000000000000000001" } ``` **Errors:** | Code | Description | |------|-------------| | 401 | Authentication required | | 403 | Permission denied (not owner) | | 404 | Certificate not found or PKI not enabled | | 409 | Certificate already revoked | **Authorization:** - Must be authenticated - Can revoke certificates you issued (issued_to matches your fingerprint) - Can revoke your own certificate (fingerprint matches)