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

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/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.

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=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):

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: 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:

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 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:

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:

  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:

# 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_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:

# 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:

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:

  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:

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_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):

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_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):

{
  "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