forked from claw/flaskpaste
add proof-of-work spam prevention
Clients must solve a SHA256 hash puzzle before paste creation. Configurable via FLASKPASTE_POW_DIFFICULTY (0 = disabled, 16 = default). Challenge tokens expire after FLASKPASTE_POW_TTL seconds (default 300).
This commit is contained in:
174
tests/test_pow.py
Normal file
174
tests/test_pow.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Tests for proof-of-work system."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
|
||||
|
||||
class TestPowChallenge:
|
||||
"""Tests for GET /challenge endpoint."""
|
||||
|
||||
def test_challenge_disabled_by_default_in_testing(self, client):
|
||||
"""Challenge endpoint returns disabled when POW_DIFFICULTY=0."""
|
||||
response = client.get("/challenge")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["enabled"] is False
|
||||
assert data["difficulty"] == 0
|
||||
|
||||
def test_challenge_returns_token_when_enabled(self, app, client):
|
||||
"""Challenge endpoint returns valid token when enabled."""
|
||||
app.config["POW_DIFFICULTY"] = 8
|
||||
|
||||
response = client.get("/challenge")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert data["enabled"] is True
|
||||
assert data["difficulty"] == 8
|
||||
assert "nonce" in data
|
||||
assert "expires" in data
|
||||
assert "token" in data
|
||||
assert len(data["nonce"]) == 32 # 16 bytes hex
|
||||
assert data["expires"] > int(time.time())
|
||||
|
||||
|
||||
class TestPowVerification:
|
||||
"""Tests for PoW verification in paste creation."""
|
||||
|
||||
def test_paste_without_pow_when_disabled(self, client, sample_text):
|
||||
"""Paste creation works without PoW when disabled."""
|
||||
response = client.post("/", data=sample_text, content_type="text/plain")
|
||||
assert response.status_code == 201
|
||||
|
||||
def test_paste_requires_pow_when_enabled(self, app, client, sample_text):
|
||||
"""Paste creation fails without PoW when enabled."""
|
||||
app.config["POW_DIFFICULTY"] = 8
|
||||
|
||||
response = client.post("/", data=sample_text, content_type="text/plain")
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "Proof-of-work required" in data["error"]
|
||||
|
||||
def test_paste_with_valid_pow(self, app, client, sample_text):
|
||||
"""Paste creation succeeds with valid PoW."""
|
||||
app.config["POW_DIFFICULTY"] = 8
|
||||
|
||||
# Get challenge
|
||||
ch_response = client.get("/challenge")
|
||||
ch_data = json.loads(ch_response.data)
|
||||
|
||||
# Solve PoW
|
||||
nonce = ch_data["nonce"]
|
||||
difficulty = ch_data["difficulty"]
|
||||
solution = solve_pow(nonce, difficulty)
|
||||
|
||||
# Submit with solution
|
||||
response = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers={
|
||||
"X-PoW-Token": ch_data["token"],
|
||||
"X-PoW-Solution": str(solution),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
def test_paste_with_invalid_solution(self, app, client, sample_text):
|
||||
"""Paste creation fails with wrong solution."""
|
||||
app.config["POW_DIFFICULTY"] = 8
|
||||
|
||||
# Get challenge
|
||||
ch_response = client.get("/challenge")
|
||||
ch_data = json.loads(ch_response.data)
|
||||
|
||||
# Submit with wrong solution
|
||||
response = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers={
|
||||
"X-PoW-Token": ch_data["token"],
|
||||
"X-PoW-Solution": "999999999",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "Insufficient work" in data["error"]
|
||||
|
||||
def test_paste_with_expired_challenge(self, app, client, sample_text):
|
||||
"""Paste creation fails with expired challenge."""
|
||||
app.config["POW_DIFFICULTY"] = 8
|
||||
app.config["POW_CHALLENGE_TTL"] = 1 # 1 second
|
||||
|
||||
# Get challenge
|
||||
ch_response = client.get("/challenge")
|
||||
ch_data = json.loads(ch_response.data)
|
||||
|
||||
# Solve PoW
|
||||
solution = solve_pow(ch_data["nonce"], ch_data["difficulty"])
|
||||
|
||||
# Wait for expiry
|
||||
time.sleep(2)
|
||||
|
||||
# Submit with expired challenge
|
||||
response = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers={
|
||||
"X-PoW-Token": ch_data["token"],
|
||||
"X-PoW-Solution": str(solution),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "expired" in data["error"].lower()
|
||||
|
||||
def test_paste_with_tampered_token(self, app, client, sample_text):
|
||||
"""Paste creation fails with tampered token."""
|
||||
app.config["POW_DIFFICULTY"] = 8
|
||||
|
||||
# Get challenge
|
||||
ch_response = client.get("/challenge")
|
||||
ch_data = json.loads(ch_response.data)
|
||||
|
||||
# Tamper with token (change difficulty)
|
||||
parts = ch_data["token"].split(":")
|
||||
parts[2] = "1" # Lower difficulty
|
||||
tampered_token = ":".join(parts)
|
||||
|
||||
# Submit with tampered token
|
||||
response = client.post(
|
||||
"/",
|
||||
data=sample_text,
|
||||
content_type="text/plain",
|
||||
headers={
|
||||
"X-PoW-Token": tampered_token,
|
||||
"X-PoW-Solution": "0",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert "signature" in data["error"].lower() or "mismatch" in data["error"].lower()
|
||||
|
||||
|
||||
def solve_pow(nonce, difficulty):
|
||||
"""Solve proof-of-work challenge (test helper)."""
|
||||
n = 0
|
||||
while True:
|
||||
work = f"{nonce}:{n}".encode()
|
||||
hash_bytes = hashlib.sha256(work).digest()
|
||||
|
||||
zero_bits = 0
|
||||
for byte in hash_bytes:
|
||||
if byte == 0:
|
||||
zero_bits += 8
|
||||
else:
|
||||
zero_bits += (8 - byte.bit_length())
|
||||
break
|
||||
|
||||
if zero_bits >= difficulty:
|
||||
return n
|
||||
n += 1
|
||||
Reference in New Issue
Block a user