# 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) - Longer default expiry (7-30 days vs 1 day) - Ability to list and delete owned pastes - Register via PKI for trusted status and admin eligibility --- ## 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, "base_difficulty": 20, "elevated": false, "expires": 1700000300, "token": "a1b2c3d4...:1700000300:20:signature" } ``` **Response (PoW enabled, anti-flood active):** ```json { "enabled": true, "nonce": "a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8", "difficulty": 24, "base_difficulty": 20, "elevated": true, "expires": 1700000300, "token": "a1b2c3d4...:1700000300:24:signature" } ``` The client must find a number `N` such that `SHA256(nonce + ":" + N)` has at least `difficulty` leading zero bits. When `elevated` is true, the difficulty has been increased due to high request volume (anti-flood protection). The `base_difficulty` shows the normal difficulty level. --- ### 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.5.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" } ``` --- ### GET /pastes List pastes for the authenticated user. Requires authentication. **Request:** ```http GET /pastes HTTP/1.1 Host: localhost:5000 X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ``` **Query Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `limit` | int | Maximum results (default: 100, max: 1000) | | `offset` | int | Skip first N results (default: 0) | | `type` | string | Filter by MIME type (glob pattern, e.g., `image/*`) | | `after` | int | Created after timestamp (Unix epoch) | | `before` | int | Created before timestamp (Unix epoch) | | `all` | int | List all pastes (admin only, set to 1) | **Response (200 OK):** ```json { "pastes": [ { "id": "a1b2c3d4e5f6", "size": 1234, "mime_type": "text/plain", "created_at": "2024-12-20T10:30:00Z", "expires_at": "2024-12-25T10:30:00Z", "burn_after_read": false, "password_protected": false, "display_name": "my notes", "owner": "a1b2c3d4..." } ], "total": 42, "limit": 100, "offset": 0 } ``` **Response (401 Unauthorized):** ```json { "error": "Authentication required" } ``` **Response (403 Forbidden - non-admin using `all=1`):** ```json { "error": "Admin access required" } ``` **Notes:** - Only admin users can use `all=1` to list all pastes - The `owner` field shows the certificate fingerprint (truncated) - First user to register via PKI becomes admin --- ### 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 ``` **Request (Display Name):** Tag a paste with a human-readable label (authenticated users only): ```http POST / HTTP/1.1 Host: localhost:5000 Content-Type: text/plain X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 X-Display-Name: project notes Content here ``` **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 "display_name": "my notes" // Only present if set (authenticated only) } ``` **Errors:** | Code | Description | |------|-------------| | 400 | No content provided | | 400 | Password too long (max 1024 chars) | | 400 | Display name too long (max 128 chars) | | 400 | Display name contains invalid characters | | 400 | Proof-of-work required (when PoW enabled) | | 400 | Proof-of-work failed (invalid/expired challenge) | | 400 | Paste too small (below minimum size) | | 413 | Paste too large | | 429 | Duplicate content rate limit exceeded | | 429 | Rate limit exceeded (per-IP throttling) | **Size Limits:** - Minimum: disabled by default (`FLASKPASTE_MIN_SIZE`, e.g. 64 bytes for encryption enforcement) - Anonymous: 3 MiB (configurable via `FLASKPASTE_MAX_ANON`) - Authenticated: 50 MiB (configurable via `FLASKPASTE_MAX_AUTH`) **Rate Limit Headers:** When rate limiting is enabled, responses include standard rate limit headers: | Header | Description | |--------|-------------| | `X-RateLimit-Limit` | Maximum requests per window | | `X-RateLimit-Remaining` | Remaining requests in current window | | `X-RateLimit-Reset` | Unix timestamp when window resets | These headers appear on both successful (201) and rate-limited (429) responses: ```http HTTP/1.1 201 Created X-RateLimit-Limit: 10 X-RateLimit-Remaining: 9 X-RateLimit-Reset: 1700000060 ``` ```http HTTP/1.1 429 Too Many Requests Retry-After: 45 X-RateLimit-Limit: 10 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1700000060 ``` Rate limits are per-IP and configurable: - `FLASKPASTE_RATE_MAX`: Base limit (default: 10 requests/minute) - `FLASKPASTE_RATE_AUTH_MULT`: Multiplier for authenticated users (default: 5x) --- ### 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 | --- ### PUT /{id} Update paste content and/or metadata. Requires authentication and ownership. **Request:** ```http PUT /abc12345 HTTP/1.1 Host: localhost:5000 X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Content-Type: text/plain Updated content ``` **Headers:** | Header | Description | |--------|-------------| | `X-Paste-Password` | Set or change paste password | | `X-Remove-Password` | `true` to remove password protection | | `X-Extend-Expiry` | Seconds to add to current expiry | | `X-Display-Name` | Set or change display name (max 128 chars) | | `X-Remove-Display-Name` | `true` to remove display name | **Response (200 OK):** ```json { "id": "abc12345", "size": 15, "mime_type": "text/plain", "expires_at": 1700086400, "display_name": "my notes" } ``` **Errors:** | Code | Description | |------|-------------| | 400 | Invalid paste ID format | | 400 | No updates provided | | 400 | Cannot update burn-after-read paste | | 400 | Display name too long (max 128 chars) | | 400 | Display name contains invalid characters | | 401 | Authentication required | | 403 | Permission denied (not owner) | | 404 | Paste not found | **Notes:** - Send content in the body to update paste content - Use headers with empty body for metadata-only updates - Display name only accepts printable characters (no control chars) --- ### DELETE /{id} Delete a paste. Requires authentication and ownership (or admin rights). **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 or admin) | | 404 | Paste not found | **Notes:** - Admin users can delete any paste, not just their own - First user to register via PKI becomes admin --- ## URL Shortener FlaskPaste includes a built-in URL shortener under the `/s/` namespace. Short URLs use 8-character base62 IDs (`[a-zA-Z0-9]`), visually distinct from paste hex IDs. ### POST /s Create a new short URL. Requires proof-of-work and respects rate limits. **Request (JSON):** ```http POST /s HTTP/1.1 Host: localhost:5000 Content-Type: application/json X-PoW-Token: X-PoW-Solution: {"url": "https://example.com/some/long/path?query=value"} ``` **Request (Raw):** ```http POST /s HTTP/1.1 Host: localhost:5000 Content-Type: text/plain X-PoW-Token: X-PoW-Solution: https://example.com/some/long/path ``` **Optional Headers:** | Header | Description | |--------|-------------| | `X-Expiry` | Custom expiry in seconds | | `X-SSL-Client-SHA1` | Client certificate fingerprint (for ownership) | **Response (201 Created):** ```json { "id": "AbCdEfGh", "url": "/s/AbCdEfGh", "target_url": "https://example.com/some/long/path?query=value", "created_at": 1700000000, "owner": "a1b2c3d4...", "expires_at": 1700003600 } ``` **Errors:** | Code | Description | |------|-------------| | 400 | No URL provided | | 400 | Invalid URL scheme (only http/https allowed) | | 400 | Invalid URL: missing host | | 400 | URL too long (max 2048 bytes) | | 400 | Proof-of-work required/failed | | 429 | Rate limit or duplicate URL limit exceeded | **Security:** - Only `http` and `https` schemes are accepted (prevents `javascript:`, `data:`, `file:` etc.) - URLs must have a valid network location (host) - Maximum URL length: 2048 bytes (configurable via `FLASKPASTE_SHORT_URL_MAX`) --- ### GET /s List short URLs owned by the authenticated user. **Request:** ```http GET /s HTTP/1.1 Host: localhost:5000 X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ``` **Query Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `limit` | int | Maximum results (default: 50, max: 200) | | `offset` | int | Pagination offset (default: 0) | **Response (200 OK):** ```json { "urls": [ { "id": "AbCdEfGh", "target_url": "https://example.com", "created_at": 1700000000, "access_count": 42, "url": "/s/AbCdEfGh" } ], "count": 1, "total": 1, "limit": 50, "offset": 0 } ``` **Errors:** | Code | Description | |------|-------------| | 401 | Authentication required | --- ### GET /s/{id} ### HEAD /s/{id} Redirect to the target URL. Returns HTTP 302 with `Location` header. **Request:** ```http GET /s/AbCdEfGh HTTP/1.1 Host: localhost:5000 ``` **Response (302 Found):** ```http HTTP/1.1 302 Found Location: https://example.com/some/long/path Cache-Control: no-store, no-cache, must-revalidate, private ``` Each access increments the short URL's access counter. **Errors:** | Code | Description | |------|-------------| | 400 | Invalid short URL ID format | | 404 | Short URL not found or expired | --- ### GET /s/{id}/info Retrieve short URL metadata without incrementing the access counter. **Request:** ```http GET /s/AbCdEfGh/info HTTP/1.1 Host: localhost:5000 ``` **Response (200 OK):** ```json { "id": "AbCdEfGh", "target_url": "https://example.com/some/long/path", "created_at": 1700000000, "last_accessed": 1700001000, "access_count": 42, "url": "/s/AbCdEfGh", "owner": "a1b2c3d4...", "expires_at": 1700086400 } ``` **Errors:** | Code | Description | |------|-------------| | 400 | Invalid short URL ID format | | 404 | Short URL not found or expired | --- ### DELETE /s/{id} Delete a short URL. Requires authentication and ownership (or admin rights). **Request:** ```http DELETE /s/AbCdEfGh HTTP/1.1 Host: localhost:5000 X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ``` **Response (200 OK):** ```json { "message": "Short URL deleted" } ``` **Errors:** | Code | Description | |------|-------------| | 400 | Invalid short URL ID format | | 401 | Authentication required | | 403 | Permission denied (not owner or admin) | | 404 | Short URL not found | --- ## MIME Type Detection FlaskPaste automatically detects MIME types using: 1. **Magic byte signatures** (highest priority) | Category | Formats | |----------|---------| | Images | PNG, JPEG, GIF, WebP, BMP, TIFF, ICO | | Video | MP4, WebM, FLV, Matroska | | Audio | MP3, FLAC, OGG | | Documents | PDF, MS Office (DOC/XLS/PPT), ZIP-based (DOCX/XLSX/ODT) | | Executables | EXE/DLL (PE), ELF (Linux), Mach-O (macOS), WASM | | Archives | ZIP, GZIP, BZIP2, XZ, ZSTD, LZ4, 7z, RAR | | Data | SQLite | 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 automatically expire based on authentication level: | Trust Level | Default Expiry | Description | |-------------|----------------|-------------| | Anonymous | 1 day | No certificate provided | | Untrusted | 7 days | Certificate not registered via PKI | | Trusted | 30 days | Certificate registered via `/register` | - Every `GET /{id}` or `GET /{id}/raw` updates the last access timestamp - Cleanup runs automatically (hourly, throttled) **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_ANON=86400 # Anonymous: 1 day export FLASKPASTE_EXPIRY_UNTRUSTED=604800 # Untrusted cert: 7 days export FLASKPASTE_EXPIRY_TRUSTED=2592000 # Trusted cert: 30 days export FLASKPASTE_MAX_EXPIRY=7776000 # Max custom expiry: 90 days ``` **Notes:** - Custom expiry is capped at `FLASKPASTE_MAX_EXPIRY` - Invalid or negative values use the default for the user's trust level - All pastes now include `expires_at` timestamp in responses --- ## 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 (fpaste encrypts by default)" } ``` **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 | --- ### Binary Content Requirement FlaskPaste can require unrecognizable binary content (encryption enforcement). **How it works:** - Content is checked for valid UTF-8 text - Plaintext (valid UTF-8) is rejected with 400 - Only binary content (invalid UTF-8) is allowed **Configuration:** ```bash export FLASKPASTE_REQUIRE_BINARY=1 # Reject plaintext (0=disabled) ``` **Response (400 Bad Request):** ```json { "error": "Recognizable format not allowed", "detected": "text/plain", "hint": "Encrypt content before uploading (fpaste encrypts by default)" } ``` **vs Entropy enforcement:** | Method | Detects | Use case | |--------|---------|----------| | Entropy | Low-entropy data | Reject unencrypted content | | Binary | Valid UTF-8 text | Reject plaintext | Use both together for maximum encryption enforcement: ```bash export FLASKPASTE_REQUIRE_BINARY=1 export FLASKPASTE_MIN_ENTROPY=6.0 export FLASKPASTE_MIN_SIZE=64 ``` --- ## 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` | | `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) --- ### GET /register/challenge Get a proof-of-work challenge for public certificate registration. Returns a challenge with higher difficulty than paste creation. **Request:** ```http GET /register/challenge HTTP/1.1 Host: localhost:5000 ``` **Response (PoW disabled):** ```json { "enabled": false, "difficulty": 0 } ``` **Response (PoW enabled):** ```json { "enabled": true, "nonce": "a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8", "difficulty": 24, "expires": 1700000300, "token": "a1b2c3d4...:1700000300:24:signature" } ``` **Notes:** - Registration difficulty defaults to 24 bits (vs 20 for paste creation) - Higher difficulty protects against automated certificate harvesting - Configurable via `FLASKPASTE_REGISTER_POW` environment variable --- ### POST /register Register for a client certificate (public endpoint with PoW protection). This endpoint allows anyone to obtain a client certificate for authentication. The CA is auto-generated on first registration if it doesn't exist. **Request (with PoW):** ```http POST /register HTTP/1.1 Host: localhost:5000 Content-Type: application/json X-PoW-Token: a1b2c3d4...:1700000300:24:signature X-PoW-Solution: 12345678 {"common_name": "alice"} ``` **Request (PoW disabled):** ```http POST /register HTTP/1.1 Host: localhost:5000 Content-Type: application/json {"common_name": "bob"} ``` **Response (200 OK):** ```http HTTP/1.1 200 OK Content-Type: application/x-pkcs12 Content-Disposition: attachment; filename="client.p12" X-Fingerprint-SHA1: b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3 X-Certificate-Expires: 1731533400 ``` **Response Headers:** | Header | Description | |--------|-------------| | `X-Fingerprint-SHA1` | SHA1 fingerprint for `X-SSL-Client-SHA1` header | | `X-Certificate-Expires` | Unix timestamp when certificate expires | **PKCS#12 Bundle Contents:** - Client certificate (signed by CA) - Client private key (EC secp384r1) - CA certificate (for trust chain) **Errors:** | Code | Description | |------|-------------| | 400 | Proof-of-work required (when enabled) | | 400 | Proof-of-work failed (invalid/expired challenge) | | 500 | PKI_CA_PASSWORD not configured | | 500 | Certificate generation failed | **Configuration:** ```bash export FLASKPASTE_REGISTER_POW=24 # Registration PoW difficulty (0=disabled) export FLASKPASTE_PKI_CA_PASSWORD="..." # Required for certificate signing export FLASKPASTE_PKI_CERT_DAYS=365 # Client certificate validity export FLASKPASTE_PKI_CA_DAYS=3650 # CA certificate validity (auto-generated) ``` **Using the Certificate:** ```bash # Extract certificate and key from PKCS#12 openssl pkcs12 -in client.p12 -clcerts -nokeys -out client.crt openssl pkcs12 -in client.p12 -nocerts -nodes -out client.key # Use with curl curl --cert client.crt --key client.key https://paste.example.com/ # Use fingerprint for header-based auth (behind reverse proxy) curl -H "X-SSL-Client-SHA1: $(openssl x509 -in client.crt -fingerprint -sha1 -noout | cut -d= -f2 | tr -d :)" \ https://paste.example.com/pastes ``` **Notes:** - `common_name` is optional; a random UUID is generated if omitted - The PKCS#12 bundle has no password (empty password) - CA is auto-generated on first registration if not present - Private key is generated server-side and included in response --- ## Audit Logging FlaskPaste logs PKI certificate lifecycle events for compliance and forensics. **Logged Events:** | Event | Trigger | Details | |-------|---------|---------| | `cert_issued` | Certificate registration or issuance | Type, CN, fingerprint, expiry | | `cert_revoked` | Certificate revocation | Serial, fingerprint | | `auth_failure` | Revoked/expired certificate used | Fingerprint, reason | **Log Format (production):** ```json { "time": "2024-12-24T10:30:00", "level": "INFO", "logger": "app.audit", "event": "cert_issued", "outcome": "success", "client_id": "a1b2c3d4...", "client_ip": "192.168.1.100", "details": {"type": "registration", "common_name": "alice"} } ``` **Notes:** - Audit logs are written to stdout in JSON format (production mode) - Events include client IP and certificate fingerprint for traceability - AUTH_FAILURE events are logged when revoked/expired certificates are used