Authenticated users can tag pastes with a human-readable label via X-Display-Name header. Supports create, update, remove, and listing. Max 128 chars, control characters rejected.
34 KiB
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/jsonfor metadata, original MIME type for raw content
Authentication
Authentication is optional and uses client certificate fingerprints passed via the X-SSL-Client-SHA1 header.
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:
GET /challenge HTTP/1.1
Host: localhost:5000
Response (PoW disabled):
{
"enabled": false,
"difficulty": 0
}
Response (PoW enabled):
{
"enabled": true,
"nonce": "a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8",
"difficulty": 20,
"base_difficulty": 20,
"elevated": false,
"expires": 1700000300,
"token": "a1b2c3d4...:1700000300:20:signature"
}
Response (PoW enabled, anti-flood active):
{
"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:
GET /health HTTP/1.1
Host: localhost:5000
Response (200 OK):
{
"status": "healthy",
"database": "ok"
}
Response (503 Service Unavailable):
{
"status": "unhealthy",
"database": "error"
}
GET /
Returns API information and usage instructions.
Request:
GET / HTTP/1.1
Host: localhost:5000
Response:
{
"name": "FlaskPaste",
"version": "1.5.0",
"endpoints": {
"GET /": "API information",
"GET /health": "Health check",
"POST /": "Create paste",
"GET /<id>": "Retrieve paste metadata",
"GET /<id>/raw": "Retrieve raw paste content",
"DELETE /<id>": "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:
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):
{
"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):
{
"error": "Authentication required"
}
Response (403 Forbidden - non-admin using all=1):
{
"error": "Admin access required"
}
Notes:
- Only admin users can use
all=1to list all pastes - The
ownerfield shows the certificate fingerprint (truncated) - First user to register via PKI becomes admin
POST /
Create a new paste.
Request (Raw Binary):
POST / HTTP/1.1
Host: localhost:5000
Content-Type: application/octet-stream
<binary content>
Request (JSON):
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:
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:
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):
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:
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):
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):
{
"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/1.1 201 Created
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
X-RateLimit-Reset: 1700000060
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:
GET /abc12345 HTTP/1.1
Host: localhost:5000
Request (Password Protected):
GET /abc12345 HTTP/1.1
Host: localhost:5000
X-Paste-Password: secretpassword
Response (200 OK):
{
"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:
GET /abc12345/raw HTTP/1.1
Host: localhost:5000
Request (Password Protected):
GET /abc12345/raw HTTP/1.1
Host: localhost:5000
X-Paste-Password: secretpassword
Response (200 OK):
HTTP/1.1 200 OK
Content-Type: image/png
Content-Disposition: inline
<binary content>
Content-Disposition: inlineis set forimage/*andtext/*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:
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):
{
"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:
DELETE /abc12345 HTTP/1.1
Host: localhost:5000
X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Response (200 OK):
{
"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):
POST /s HTTP/1.1
Host: localhost:5000
Content-Type: application/json
X-PoW-Token: <token>
X-PoW-Solution: <solution>
{"url": "https://example.com/some/long/path?query=value"}
Request (Raw):
POST /s HTTP/1.1
Host: localhost:5000
Content-Type: text/plain
X-PoW-Token: <token>
X-PoW-Solution: <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):
{
"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
httpandhttpsschemes are accepted (preventsjavascript:,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:
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):
{
"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:
GET /s/AbCdEfGh HTTP/1.1
Host: localhost:5000
Response (302 Found):
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:
GET /s/AbCdEfGh/info HTTP/1.1
Host: localhost:5000
Response (200 OK):
{
"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:
DELETE /s/AbCdEfGh HTTP/1.1
Host: localhost:5000
X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Response (200 OK):
{
"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:
-
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 -
Explicit Content-Type header (if not generic)
-
UTF-8 detection (falls back to
text/plain) -
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}orGET /{id}/rawupdates the last access timestamp - Cleanup runs automatically (hourly, throttled)
Custom Expiry:
Pastes can have custom expiry times using the X-Expiry header:
# Paste expires in 1 hour
curl -H "X-Expiry: 3600" --data-binary @file.txt http://host/
Configuration:
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_attimestamp in responses
Burn-After-Read
Single-access pastes that delete themselves after first retrieval.
How it works:
- Set
X-Burn-After-Read: trueheader on creation - First
GET /{id}/rawreturns content and deletes paste - Subsequent requests return 404
- Metadata
GET /{id}does not trigger burn HEADrequests do not trigger burn
Usage:
# 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: trueheader 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:
POST / HTTP/1.1
Host: localhost:5000
Content-Type: text/plain
X-Paste-Password: mysecretpassword
Protected content here
Response (201 Created):
{
"id": "abc12345",
"url": "/abc12345",
"raw": "/abc12345/raw",
"mime_type": "text/plain",
"created_at": 1700000000,
"password_protected": true
}
Accessing protected paste:
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):
{
"error": "Password required",
"password_protected": true
}
Response (403 Forbidden):
{
"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:
# 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):
{
"error": "Duplicate content rate limit exceeded",
"count": 3,
"window_seconds": 3600
}
Configuration:
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:
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):
{
"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:
export FLASKPASTE_REQUIRE_BINARY=1 # Reject plaintext (0=disabled)
Response (400 Bad Request):
{
"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:
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:
- Client requests a challenge via
GET /challenge - Server returns a nonce, difficulty, expiry time, and signed token
- Client computes SHA256 hashes until finding one with enough leading zero bits
- Client submits paste with
X-PoW-TokenandX-PoW-Solutionheaders
Algorithm:
For N = 0, 1, 2, ...:
hash = SHA256(nonce + ":" + N)
if leading_zero_bits(hash) >= difficulty:
return N as solution
Configuration:
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:
{
"error": "Proof-of-work required",
"hint": "GET /challenge for a new challenge"
}
{
"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:
{
"error": "Description of the error"
}
For size limit errors (413):
{
"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=<request-id>]
Example:
# 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:
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:
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:
GET /pki HTTP/1.1
Host: localhost:5000
Response (PKI disabled):
{
"enabled": false
}
Response (PKI enabled, no CA):
{
"enabled": true,
"ca_exists": false,
"hint": "POST /pki/ca to generate CA"
}
Response (PKI enabled with CA):
{
"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:
POST /pki/ca HTTP/1.1
Host: localhost:5000
Content-Type: application/json
{"common_name": "My CA"}
Response (201 Created):
{
"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:
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:
POST /pki/issue HTTP/1.1
Host: localhost:5000
Content-Type: application/json
{"common_name": "alice"}
Response (201 Created):
{
"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):
GET /pki/certs HTTP/1.1
Host: localhost:5000
X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Response (200 OK):
{
"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:
POST /pki/revoke/00000000000000000000000000000001 HTTP/1.1
Host: localhost:5000
X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Response (200 OK):
{
"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:
GET /register/challenge HTTP/1.1
Host: localhost:5000
Response (PoW disabled):
{
"enabled": false,
"difficulty": 0
}
Response (PoW enabled):
{
"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_POWenvironment 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):
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):
POST /register HTTP/1.1
Host: localhost:5000
Content-Type: application/json
{"common_name": "bob"}
Response (200 OK):
HTTP/1.1 200 OK
Content-Type: application/x-pkcs12
Content-Disposition: attachment; filename="client.p12"
X-Fingerprint-SHA1: b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3
X-Certificate-Expires: 1731533400
<binary PKCS#12 data>
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:
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:
# 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_nameis 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):
{
"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