Files
Username 8ebabfe102
All checks were successful
CI / Lint & Security Scan (push) Successful in 47s
CI / Build & Push Image (push) Successful in 22s
CI / Harbor Vulnerability Scan (push) Successful in 37s
pastes: add display_name field
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.
2026-02-24 12:55:44 +01:00

1530 lines
34 KiB
Markdown

# 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 /<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:**
```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
```
**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
```
**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
```
**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
```
**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
```
**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
```
**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
<binary content>
```
- `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
```
**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: <token>
X-PoW-Solution: <solution>
```
**Request (Raw):**
```http
POST /s HTTP/1.1
Host: localhost:5000
Content-Type: text/plain
X-PoW-Token: <token>
X-PoW-Solution: <solution>
```
**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
```
**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=<request-id>]`
**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
```
**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
```
**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
```
**Request (PoW disabled):**
```http
POST /register HTTP/1.1
Host: localhost:5000
Content-Type: application/json
```
**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
<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:**
```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