Files
flaskpaste/documentation/api.md
Username 4e38517faf pki: add minimal certificate authority
- CA generation with encrypted private key storage (AES-256-GCM)
- Client certificate issuance with configurable validity
- Certificate revocation with status tracking
- SHA1 fingerprint integration with existing mTLS auth
- API endpoints: /pki/status, /pki/ca, /pki/issue, /pki/revoke
- CLI commands: fpaste pki status/issue/revoke
- Comprehensive test coverage
2025-12-20 17:20:15 +01:00

20 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)
  • Ability to delete owned pastes

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,
  "expires": 1700000300,
  "token": "a1b2c3d4...:1700000300:20:signature"
}

The client must find a number N such that SHA256(nonce + ":" + N) has at least difficulty leading zero bits.


GET /health

Health check endpoint for load balancers and monitoring.

Request:

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.2.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"
}

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

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
}

Errors:

Code Description
400 No content provided
400 Password too long (max 1024 chars)
400 Proof-of-work required (when PoW enabled)
400 Proof-of-work failed (invalid/expired challenge)
413 Paste too large
429 Duplicate content rate limit exceeded

Size Limits:

  • Anonymous: 3 MiB (configurable via FLASKPASTE_MAX_ANON)
  • Authenticated: 50 MiB (configurable via FLASKPASTE_MAX_AUTH)

GET /{id}

HEAD /{id}

Retrieve paste metadata. HEAD returns headers only (no body).

Request:

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

DELETE /{id}

Delete a paste. Requires authentication and ownership.

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)
404 Paste not found

MIME Type Detection

FlaskPaste automatically detects MIME types using:

  1. Magic byte signatures (highest priority)

    • PNG: \x89PNG\r\n\x1a\n
    • JPEG: \xff\xd8\xff
    • GIF: GIF87a or GIF89a
    • WebP: RIFF....WEBP
    • ZIP: PK\x03\x04
    • PDF: %PDF
    • GZIP: \x1f\x8b
  2. Explicit Content-Type header (if not generic)

  3. UTF-8 detection (falls back to text/plain)

  4. Binary fallback (application/octet-stream)


Paste Expiry

Pastes expire based on last access time (default: 5 days).

  • Every GET /{id} or GET /{id}/raw updates the last access timestamp
  • Cleanup runs automatically (hourly, throttled)
  • Configurable via FLASKPASTE_EXPIRY environment variable

Custom Expiry:

Pastes can have custom expiry times using the X-Expiry header:

# Paste expires in 1 hour
curl -H "X-Expiry: 3600" --data-binary @file.txt http://host/

Configuration:

export FLASKPASTE_EXPIRY=432000       # Default expiry: 5 days
export FLASKPASTE_MAX_EXPIRY=2592000  # Max custom expiry: 30 days

Notes:

  • Custom expiry is capped at FLASKPASTE_MAX_EXPIRY
  • Invalid or negative values are ignored (uses default)
  • Response includes expires_at timestamp when custom expiry is set

Burn-After-Read

Single-access pastes that delete themselves after first retrieval.

How it works:

  • Set X-Burn-After-Read: true header on creation
  • First GET /{id}/raw returns content and deletes paste
  • Subsequent requests return 404
  • Metadata GET /{id} does not trigger burn
  • HEAD requests do not trigger burn

Usage:

# 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 (-e flag in fpaste)"
}

Caveats:

  • Small data is exempt (configurable via MIN_ENTROPY_SIZE, default 256 bytes)
  • Compressed data (gzip, zip) also has high entropy — not distinguishable from encrypted
  • This is a heuristic, not cryptographic proof of encryption

Recommended thresholds:

Threshold Effect
0 Disabled (default)
5.0 Blocks most plaintext
6.0 Requires encryption or compression
7.0 Requires encryption + sufficient size

Proof-of-Work

FlaskPaste includes an optional proof-of-work system to prevent automated spam.

How it works:

  1. Client requests a challenge via GET /challenge
  2. Server returns a nonce, difficulty, expiry time, and signed token
  3. Client computes SHA256 hashes until finding one with enough leading zero bits
  4. Client submits paste with X-PoW-Token and X-PoW-Solution headers

Algorithm:

For N = 0, 1, 2, ...:
  hash = SHA256(nonce + ":" + N)
  if leading_zero_bits(hash) >= difficulty:
    return N as solution

Configuration:

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
X-XSS-Protection 1; mode=block
Referrer-Policy strict-origin-when-cross-origin
Content-Security-Policy default-src 'none'; frame-ancestors 'none'
Permissions-Policy geolocation=(), microphone=(), camera=()
Strict-Transport-Security max-age=31536000; includeSubDomains
Cache-Control no-store, no-cache, must-revalidate, private
Pragma no-cache

Request Tracing

All requests include an X-Request-ID header for log correlation:

  • If the client provides X-Request-ID, it is passed through
  • If not provided, a UUID is generated
  • The ID is echoed back in the response
  • All log entries include [rid=<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)