From 8addf2d9e88e0da73e29755f532b4b5b81532102 Mon Sep 17 00:00:00 2001 From: Username Date: Sat, 20 Dec 2025 06:57:50 +0100 Subject: [PATCH] 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. --- app/api/routes.py | 44 +++++++++++++++++++++++ app/config.py | 5 +++ documentation/api.md | 40 +++++++++++++++++++++ tests/test_abuse_prevention.py | 65 ++++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+) diff --git a/app/api/routes.py b/app/api/routes.py index e5dec7c..71f6952 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -3,6 +3,7 @@ import hashlib import hmac import json +import math import os import re 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: """Get or generate the PoW signing secret.""" global _pow_secret_cache @@ -425,6 +453,22 @@ def create_paste(): "authenticated": owner is not None, }, 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 content_hash = hashlib.sha256(content).hexdigest() is_allowed, dedup_count = check_content_hash(content_hash) diff --git a/app/config.py b/app/config.py index 44c26b0..f0f1089 100644 --- a/app/config.py +++ b/app/config.py @@ -27,6 +27,11 @@ class Config: 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 + # 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 # SECURITY: The X-SSL-Client-SHA1 header is trusted for authentication. # This header MUST only come from a trusted reverse proxy that validates diff --git a/documentation/api.md b/documentation/api.md index da416fe..209272b 100644 --- a/documentation/api.md +++ b/documentation/api.md @@ -343,6 +343,46 @@ export FLASKPASTE_DEDUP_MAX=3 # Max duplicates per window (default: 3) --- +## 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=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. diff --git a/tests/test_abuse_prevention.py b/tests/test_abuse_prevention.py index e7acfe8..f99e2e8 100644 --- a/tests/test_abuse_prevention.py +++ b/tests/test_abuse_prevention.py @@ -231,3 +231,68 @@ class TestWindowReset: is_allowed, count = check_content_hash(content_hash) assert is_allowed is True 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