Skip to content

Commit d98e7df

Browse files
authored
Merge pull request #22 from QWED-AI/security/fixes
Security: Fix ReDoS vulnerability and Upgrade to HMAC-SHA256
2 parents 986195f + afa5e59 commit d98e7df

5 files changed

Lines changed: 82 additions & 5 deletions

File tree

src/qwed_new/auth/middleware.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ async def get_api_key(
2727
# Hash the provided key to compare with stored hash
2828
# The key format is qwed_live_<random>
2929
# We store the hash of the full key string
30-
hashed_key = hashlib.sha256(api_key_header.encode()).hexdigest()
30+
hashed_key = hash_api_key(api_key_header)
3131

3232
# Query database for the key
3333
statement = select(ApiKey).where(

src/qwed_new/auth/security.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,36 @@ def generate_api_key(prefix: str = "qwed_live") -> tuple[str, str]:
6565
plaintext_key = f"{prefix}_{random_part}"
6666

6767
# Hash the key for storage
68-
key_hash = hashlib.sha256(plaintext_key.encode()).hexdigest()
68+
key_hash = hash_api_key(plaintext_key)
6969

7070
return plaintext_key, key_hash
7171

72+
7273
def hash_api_key(api_key: str) -> str:
73-
"""Hash an API key for comparison."""
74-
return hashlib.sha256(api_key.encode()).hexdigest()
74+
"""
75+
Derive a hash for an API key using PBKDF2-HMAC-SHA256.
76+
77+
This is intentionally computationally expensive to make brute-force attacks
78+
against stored API key hashes more difficult, while remaining deterministic
79+
for lookup purposes.
80+
"""
81+
# Derive a salt from SECRET_KEY; fall back to a constant development salt.
82+
if isinstance(SECRET_KEY, str):
83+
secret_bytes = SECRET_KEY.encode()
84+
else:
85+
secret_bytes = b"default_dev_salt" # Fallback if secret is somehow bytes or None
86+
87+
# Namespace the salt for API key hashing to avoid cross-protocol reuse.
88+
salt = secret_bytes + b":qwed_api_key"
89+
90+
# Use PBKDF2-HMAC-SHA256 with a reasonable iteration count.
91+
dk = hashlib.pbkdf2_hmac(
92+
"sha256",
93+
api_key.encode("utf-8"),
94+
salt,
95+
100_000,
96+
)
97+
return dk.hex()
7598

7699
def mask_api_key(api_key: str) -> str:
77100
"""

src/qwed_new/core/image_verifier.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,17 @@ def verify_image(
120120
"methods_used": [],
121121
"engine": "ImageVerifier"
122122
}
123+
124+
# Security: Prevent ReDoS attacks on regexes
125+
if len(claim) > 500:
126+
return {
127+
"verdict": "INCONCLUSIVE",
128+
"confidence": 0.0,
129+
"reasoning": "Claim text too long (max 500 chars) - Security ReDoS protection",
130+
"analysis": {},
131+
"methods_used": [],
132+
"engine": "ImageVerifier"
133+
}
123134

124135
methods_used = []
125136
analysis = {}

src/qwed_new/core/tenant_context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ async def get_current_tenant(
3131
Returns the authenticated tenant context.
3232
"""
3333
# 1. Hash the provided key
34-
hashed_key = hashlib.sha256(x_api_key.encode()).hexdigest()
34+
hashed_key = hash_api_key(x_api_key)
3535

3636
# 2. Look up API key
3737
statement = select(ApiKey).where(ApiKey.key_hash == hashed_key, ApiKey.is_active == True)

tests/test_security_fixes.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from qwed_new.auth.security import hash_api_key
2+
from qwed_new.core.image_verifier import ImageVerifier
3+
4+
def test_hash_api_key_pbkdf2():
5+
"""
6+
Test that hash_api_key uses PBKDF2 (returns a hex string).
7+
This ensures the new security logic is covered.
8+
"""
9+
key = "qwed_live_test_key_12345"
10+
hashed = hash_api_key(key)
11+
12+
# Check it returns a string
13+
assert isinstance(hashed, str)
14+
# Check it's hex
15+
assert all(c in '0123456789abcdef' for c in hashed)
16+
# Length of SHA256 hex digest is 64 chars
17+
assert len(hashed) == 64
18+
19+
# Deterministic check
20+
assert hash_api_key(key) == hashed
21+
22+
def test_image_verifier_redos_protection():
23+
"""
24+
Test that ImageVerifier rejects overly long claim strings (ReDoS protection).
25+
"""
26+
verifier = ImageVerifier(use_vlm_fallback=False)
27+
28+
# Create a dummy image (valid PNG header)
29+
dummy_image = b'\x89PNG\r\n\x1a\n' + b'\x00' * 100
30+
31+
# 1. Normal claim
32+
normal_claim = "Is this a cat?"
33+
result = verifier.verify_image(dummy_image, normal_claim)
34+
# Should perform analysis (likely INCONCLUSIVE or fail elsewhere, but not ReDoS block)
35+
assert result["verdict"] != "INCONCLUSIVE" or result["reasoning"] != "Claim text too long (max 500 chars) - Security ReDoS protection"
36+
37+
# 2. Long claim (ReDoS attack simulation)
38+
long_claim = "a" * 600 # Exceeds 500 char limit
39+
result_blocked = verifier.verify_image(dummy_image, long_claim)
40+
41+
assert result_blocked["verdict"] == "INCONCLUSIVE"
42+
assert "Claim text too long" in result_blocked["reasoning"]
43+
assert result_blocked["confidence"] == 0.0

0 commit comments

Comments
 (0)