forked from claw/flaskpaste
add entropy enforcement for optional encryption requirement
Shannon entropy check rejects low-entropy content when MIN_ENTROPY > 0. Encrypted data ~7.5-8.0 bits/byte, plaintext ~4.0-5.0 bits/byte. Configurable via FLASKPASTE_MIN_ENTROPY environment variable.
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
@@ -58,6 +59,33 @@ MAGIC_SIGNATURES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_entropy(data: bytes) -> float:
|
||||||
|
"""Calculate Shannon entropy in bits per byte.
|
||||||
|
|
||||||
|
Returns value between 0 (uniform) and 8 (perfectly random).
|
||||||
|
Encrypted/compressed data: ~7.5-8.0
|
||||||
|
English text: ~4.0-5.0
|
||||||
|
Binary executables: ~5.0-6.5
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Count byte frequencies
|
||||||
|
freq = [0] * 256
|
||||||
|
for byte in data:
|
||||||
|
freq[byte] += 1
|
||||||
|
|
||||||
|
# Calculate entropy
|
||||||
|
length = len(data)
|
||||||
|
entropy = 0.0
|
||||||
|
for count in freq:
|
||||||
|
if count > 0:
|
||||||
|
p = count / length
|
||||||
|
entropy -= p * math.log2(p)
|
||||||
|
|
||||||
|
return entropy
|
||||||
|
|
||||||
|
|
||||||
def _get_pow_secret() -> bytes:
|
def _get_pow_secret() -> bytes:
|
||||||
"""Get or generate the PoW signing secret."""
|
"""Get or generate the PoW signing secret."""
|
||||||
global _pow_secret_cache
|
global _pow_secret_cache
|
||||||
@@ -425,6 +453,22 @@ def create_paste():
|
|||||||
"authenticated": owner is not None,
|
"authenticated": owner is not None,
|
||||||
}, 413)
|
}, 413)
|
||||||
|
|
||||||
|
# Check minimum entropy requirement (encryption enforcement)
|
||||||
|
min_entropy = current_app.config.get("MIN_ENTROPY", 0)
|
||||||
|
if min_entropy > 0:
|
||||||
|
entropy = _calculate_entropy(content)
|
||||||
|
if entropy < min_entropy:
|
||||||
|
current_app.logger.warning(
|
||||||
|
"Low entropy rejected: %.2f < %.2f from=%s",
|
||||||
|
entropy, min_entropy, request.remote_addr
|
||||||
|
)
|
||||||
|
return _json_response({
|
||||||
|
"error": "Content entropy too low",
|
||||||
|
"entropy": round(entropy, 2),
|
||||||
|
"min_entropy": min_entropy,
|
||||||
|
"hint": "Encrypt content before uploading (-e flag in fpaste)",
|
||||||
|
}, 400)
|
||||||
|
|
||||||
# Check content deduplication threshold
|
# Check content deduplication threshold
|
||||||
content_hash = hashlib.sha256(content).hexdigest()
|
content_hash = hashlib.sha256(content).hexdigest()
|
||||||
is_allowed, dedup_count = check_content_hash(content_hash)
|
is_allowed, dedup_count = check_content_hash(content_hash)
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class Config:
|
|||||||
CONTENT_DEDUP_WINDOW = int(os.environ.get("FLASKPASTE_DEDUP_WINDOW", 3600)) # 1 hour
|
CONTENT_DEDUP_WINDOW = int(os.environ.get("FLASKPASTE_DEDUP_WINDOW", 3600)) # 1 hour
|
||||||
CONTENT_DEDUP_MAX = int(os.environ.get("FLASKPASTE_DEDUP_MAX", 3)) # max 3 per window
|
CONTENT_DEDUP_MAX = int(os.environ.get("FLASKPASTE_DEDUP_MAX", 3)) # max 3 per window
|
||||||
|
|
||||||
|
# Minimum entropy requirement (0 = disabled)
|
||||||
|
# Encrypted data has ~7.5-8.0 bits/byte, plaintext ~4.0-5.0
|
||||||
|
# Set to 7.0+ to effectively require encryption
|
||||||
|
MIN_ENTROPY = float(os.environ.get("FLASKPASTE_MIN_ENTROPY", 0))
|
||||||
|
|
||||||
# Reverse proxy trust configuration
|
# Reverse proxy trust configuration
|
||||||
# SECURITY: The X-SSL-Client-SHA1 header is trusted for authentication.
|
# SECURITY: The X-SSL-Client-SHA1 header is trusted for authentication.
|
||||||
# This header MUST only come from a trusted reverse proxy that validates
|
# This header MUST only come from a trusted reverse proxy that validates
|
||||||
|
|||||||
@@ -343,6 +343,46 @@ export FLASKPASTE_DEDUP_MAX=3 # Max duplicates per window (default: 3)
|
|||||||
## Entropy Enforcement
|
## Entropy Enforcement
|
||||||
|
|
||||||
FlaskPaste can require minimum content entropy to enforce client-side encryption.
|
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=7.0 # Require ~encryption-level entropy (0=disabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (400 Bad Request):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Content entropy too low",
|
||||||
|
"entropy": 4.12,
|
||||||
|
"min_entropy": 7.0,
|
||||||
|
"hint": "Encrypt content before uploading (-e flag in fpaste)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Caveats:**
|
||||||
|
- Small data (<256 bytes) has naturally lower measured entropy even when encrypted
|
||||||
|
- 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:**
|
**How it works:**
|
||||||
1. Client requests a challenge via `GET /challenge`
|
1. Client requests a challenge via `GET /challenge`
|
||||||
|
|||||||
@@ -231,3 +231,68 @@ class TestWindowReset:
|
|||||||
is_allowed, count = check_content_hash(content_hash)
|
is_allowed, count = check_content_hash(content_hash)
|
||||||
assert is_allowed is True
|
assert is_allowed is True
|
||||||
assert count == 1 # Counter reset
|
assert count == 1 # Counter reset
|
||||||
|
|
||||||
|
|
||||||
|
class TestEntropyEnforcement:
|
||||||
|
"""Test minimum entropy requirement."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def entropy_app(self):
|
||||||
|
"""Create app with entropy requirement enabled."""
|
||||||
|
app = create_app("testing")
|
||||||
|
app.config["MIN_ENTROPY"] = 6.0 # Require high entropy
|
||||||
|
return app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def entropy_client(self, entropy_app):
|
||||||
|
"""Create test client with entropy requirement."""
|
||||||
|
return entropy_app.test_client()
|
||||||
|
|
||||||
|
def test_plaintext_rejected(self, entropy_client):
|
||||||
|
"""Plaintext content should be rejected when entropy required."""
|
||||||
|
response = entropy_client.post(
|
||||||
|
"/",
|
||||||
|
data=b"Hello, this is plain English text with low entropy.",
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["error"] == "Content entropy too low"
|
||||||
|
assert "entropy" in data
|
||||||
|
assert "min_entropy" in data
|
||||||
|
assert "hint" in data
|
||||||
|
|
||||||
|
def test_random_data_accepted(self, entropy_client):
|
||||||
|
"""Random/encrypted data should pass entropy check."""
|
||||||
|
import os
|
||||||
|
random_data = os.urandom(512) # High entropy random bytes
|
||||||
|
|
||||||
|
response = entropy_client.post(
|
||||||
|
"/",
|
||||||
|
data=random_data,
|
||||||
|
content_type="application/octet-stream",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
def test_entropy_disabled_by_default(self, client, sample_text):
|
||||||
|
"""Entropy check should be disabled by default (MIN_ENTROPY=0)."""
|
||||||
|
# Default testing config has MIN_ENTROPY=0
|
||||||
|
response = client.post(
|
||||||
|
"/",
|
||||||
|
data=sample_text,
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
def test_repeated_bytes_rejected(self, entropy_client):
|
||||||
|
"""Repeated bytes have zero entropy and should be rejected."""
|
||||||
|
response = entropy_client.post(
|
||||||
|
"/",
|
||||||
|
data=b"a" * 1000,
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["entropy"] == 0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user