Add security tests for configurations, permissions, and authentication
- **Configurations:** Test minimum `SECRET_KEY` length validation to prevent weak JWT signing keys. Validate proper handling of secure defaults. - **Permissions:** Add tests for inactive user blocking, API access control, and superuser privilege escalation across organizational roles. - **Authentication:** Test logout safety, session revocation, token replay prevention, and defense against JWT algorithm confusion attacks. - Include `# pragma: no cover` for unreachable defensive code in security-sensitive areas.
This commit is contained in:
269
backend/tests/core/test_auth_security.py
Normal file
269
backend/tests/core/test_auth_security.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Security tests for authentication module (app/core/auth.py).
|
||||
|
||||
Critical security tests covering:
|
||||
- JWT algorithm confusion attacks (CVE-2015-9235)
|
||||
- Algorithm substitution attacks
|
||||
- Token validation security
|
||||
|
||||
These tests cover critical security vulnerabilities that could be exploited.
|
||||
"""
|
||||
import pytest
|
||||
from jose import jwt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.core.auth import decode_token, create_access_token, TokenInvalidError
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class TestJWTAlgorithmSecurityAttacks:
|
||||
"""
|
||||
Test JWT algorithm confusion attacks.
|
||||
|
||||
CVE-2015-9235: Critical vulnerability where attackers can bypass JWT signature
|
||||
verification by using "alg: none" or substituting algorithms.
|
||||
|
||||
References:
|
||||
- https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/
|
||||
- https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2015-9235
|
||||
|
||||
Covers lines: auth.py:209, auth.py:212
|
||||
"""
|
||||
|
||||
def test_reject_algorithm_none_attack(self):
|
||||
"""
|
||||
Test that tokens with "alg: none" are rejected.
|
||||
|
||||
Attack Scenario:
|
||||
Attacker creates a token with "alg: none" to bypass signature verification.
|
||||
|
||||
NOTE: Lines 209 and 212 in auth.py are DEFENSIVE CODE that's never reached
|
||||
because python-jose library rejects "none" algorithm tokens BEFORE we get there.
|
||||
This is good for security! The library throws JWTError which becomes TokenInvalidError.
|
||||
|
||||
This test verifies the overall protection works, even though our defensive
|
||||
checks at lines 209-212 don't execute because the library catches it first.
|
||||
"""
|
||||
# Create a payload that would normally be valid (using timestamps)
|
||||
import time
|
||||
now = int(time.time())
|
||||
|
||||
payload = {
|
||||
"sub": "user123",
|
||||
"exp": now + 3600, # 1 hour from now
|
||||
"iat": now,
|
||||
"type": "access"
|
||||
}
|
||||
|
||||
# Craft a malicious token with "alg: none"
|
||||
# We manually encode to bypass library protections
|
||||
import base64
|
||||
import json
|
||||
|
||||
header = {"alg": "none", "typ": "JWT"}
|
||||
header_encoded = base64.urlsafe_b64encode(
|
||||
json.dumps(header).encode()
|
||||
).decode().rstrip("=")
|
||||
|
||||
payload_encoded = base64.urlsafe_b64encode(
|
||||
json.dumps(payload).encode()
|
||||
).decode().rstrip("=")
|
||||
|
||||
# Token with no signature (algorithm "none")
|
||||
malicious_token = f"{header_encoded}.{payload_encoded}."
|
||||
|
||||
# Should reject the token (library catches it, which is good!)
|
||||
with pytest.raises(TokenInvalidError):
|
||||
decode_token(malicious_token)
|
||||
|
||||
def test_reject_algorithm_none_lowercase(self):
|
||||
"""
|
||||
Test that tokens with "alg: NONE" (uppercase) are also rejected.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": "user123",
|
||||
"exp": now + 3600,
|
||||
"iat": now,
|
||||
"type": "access"
|
||||
}
|
||||
|
||||
# Try uppercase "NONE"
|
||||
header = {"alg": "NONE", "typ": "JWT"}
|
||||
header_encoded = base64.urlsafe_b64encode(
|
||||
json.dumps(header).encode()
|
||||
).decode().rstrip("=")
|
||||
|
||||
payload_encoded = base64.urlsafe_b64encode(
|
||||
json.dumps(payload).encode()
|
||||
).decode().rstrip("=")
|
||||
|
||||
malicious_token = f"{header_encoded}.{payload_encoded}."
|
||||
|
||||
with pytest.raises(TokenInvalidError):
|
||||
decode_token(malicious_token)
|
||||
|
||||
def test_reject_algorithm_substitution_hs256_to_rs256(self):
|
||||
"""
|
||||
Test that tokens with wrong algorithm are rejected.
|
||||
|
||||
Attack Scenario:
|
||||
Attacker changes algorithm from HS256 to RS256, attempting to use
|
||||
the public key as the HMAC secret. This could allow token forgery.
|
||||
|
||||
Reference: https://www.nccgroup.com/us/about-us/newsroom-and-events/blog/2019/january/jwt-algorithm-confusion/
|
||||
|
||||
NOTE: Like the "none" algorithm test, python-jose library catches this
|
||||
before our defensive checks at line 212. This is good for security!
|
||||
"""
|
||||
import time
|
||||
now = int(time.time())
|
||||
|
||||
# Create a valid payload
|
||||
payload = {
|
||||
"sub": "user123",
|
||||
"exp": now + 3600,
|
||||
"iat": now,
|
||||
"type": "access"
|
||||
}
|
||||
|
||||
# Encode with wrong algorithm (RS256 instead of HS256)
|
||||
# This simulates an attacker trying algorithm substitution
|
||||
wrong_algorithm = "RS256" if settings.ALGORITHM == "HS256" else "HS256"
|
||||
|
||||
try:
|
||||
malicious_token = jwt.encode(
|
||||
payload,
|
||||
settings.SECRET_KEY,
|
||||
algorithm=wrong_algorithm
|
||||
)
|
||||
|
||||
# Should reject the token (library catches mismatch)
|
||||
with pytest.raises(TokenInvalidError):
|
||||
decode_token(malicious_token)
|
||||
except Exception:
|
||||
# If encoding fails, that's also acceptable (library protection)
|
||||
pass
|
||||
|
||||
def test_reject_hs384_when_hs256_expected(self):
|
||||
"""
|
||||
Test that HS384 tokens are rejected when HS256 is configured.
|
||||
|
||||
Prevents algorithm downgrade/upgrade attacks.
|
||||
"""
|
||||
import time
|
||||
now = int(time.time())
|
||||
|
||||
payload = {
|
||||
"sub": "user123",
|
||||
"exp": now + 3600,
|
||||
"iat": now,
|
||||
"type": "access"
|
||||
}
|
||||
|
||||
# Create token with HS384 instead of HS256
|
||||
try:
|
||||
malicious_token = jwt.encode(
|
||||
payload,
|
||||
settings.SECRET_KEY,
|
||||
algorithm="HS384"
|
||||
)
|
||||
|
||||
with pytest.raises(TokenInvalidError):
|
||||
decode_token(malicious_token)
|
||||
except Exception:
|
||||
# If encoding fails, that's also fine
|
||||
pass
|
||||
|
||||
def test_valid_token_with_correct_algorithm_accepted(self):
|
||||
"""
|
||||
Sanity check: Valid tokens with correct algorithm should still work.
|
||||
|
||||
Ensures our security checks don't break legitimate tokens.
|
||||
"""
|
||||
# Create a valid access token using the app's own function
|
||||
token = create_access_token(subject="user123")
|
||||
|
||||
# Should decode successfully
|
||||
token_data = decode_token(token)
|
||||
assert token_data.sub == "user123" # TokenPayload uses 'sub', not 'user_id'
|
||||
assert token_data.type == "access"
|
||||
|
||||
def test_algorithm_case_sensitivity(self):
|
||||
"""
|
||||
Test that algorithm matching is case-insensitive (uppercase check in code).
|
||||
|
||||
The code uses .upper() for comparison, ensuring "hs256" matches "HS256".
|
||||
"""
|
||||
# Create a valid token
|
||||
token = create_access_token(subject="user123")
|
||||
|
||||
# Should work regardless of case in settings
|
||||
# (This is a sanity check that our comparison logic handles case)
|
||||
token_data = decode_token(token)
|
||||
assert token_data.sub == "user123" # TokenPayload uses 'sub', not 'user_id'
|
||||
|
||||
|
||||
class TestJWTSecurityEdgeCases:
|
||||
"""Additional JWT security edge cases."""
|
||||
|
||||
def test_token_with_missing_algorithm_header(self):
|
||||
"""
|
||||
Test handling of malformed token without algorithm header.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
now = int(time.time())
|
||||
|
||||
# Create token without "alg" in header
|
||||
header = {"typ": "JWT"} # Missing "alg"
|
||||
payload = {
|
||||
"sub": "user123",
|
||||
"exp": now + 3600,
|
||||
"iat": now,
|
||||
"type": "access"
|
||||
}
|
||||
|
||||
header_encoded = base64.urlsafe_b64encode(
|
||||
json.dumps(header).encode()
|
||||
).decode().rstrip("=")
|
||||
|
||||
payload_encoded = base64.urlsafe_b64encode(
|
||||
json.dumps(payload).encode()
|
||||
).decode().rstrip("=")
|
||||
|
||||
malicious_token = f"{header_encoded}.{payload_encoded}.fake_signature"
|
||||
|
||||
# Should reject due to missing or invalid algorithm
|
||||
with pytest.raises(TokenInvalidError):
|
||||
decode_token(malicious_token)
|
||||
|
||||
def test_completely_malformed_token(self):
|
||||
"""Test that completely malformed tokens are rejected."""
|
||||
with pytest.raises(TokenInvalidError):
|
||||
decode_token("not.a.valid.jwt.token.at.all")
|
||||
|
||||
def test_token_with_invalid_json_payload(self):
|
||||
"""Test token with malformed JSON in payload."""
|
||||
import base64
|
||||
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
header_encoded = base64.urlsafe_b64encode(
|
||||
b'{"alg":"HS256","typ":"JWT"}'
|
||||
).decode().rstrip("=")
|
||||
|
||||
# Invalid JSON (missing closing brace)
|
||||
invalid_payload_encoded = base64.urlsafe_b64encode(
|
||||
b'{"sub":"user123"' # Invalid JSON
|
||||
).decode().rstrip("=")
|
||||
|
||||
malicious_token = f"{header_encoded}.{invalid_payload_encoded}.fake_sig"
|
||||
|
||||
with pytest.raises(TokenInvalidError):
|
||||
decode_token(malicious_token)
|
||||
135
backend/tests/core/test_config_security.py
Normal file
135
backend/tests/core/test_config_security.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Security tests for configuration validation (app/core/config.py).
|
||||
|
||||
Critical security tests covering:
|
||||
- SECRET_KEY minimum length validation (prevents weak JWT signing keys)
|
||||
|
||||
These tests prevent security misconfigurations.
|
||||
"""
|
||||
import pytest
|
||||
import os
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
class TestSecretKeySecurityValidation:
|
||||
"""
|
||||
Test SECRET_KEY security validation (config.py line 109).
|
||||
|
||||
Attack Prevention:
|
||||
Short SECRET_KEYs can be brute-forced, compromising JWT token security.
|
||||
System must enforce minimum 32-character requirement.
|
||||
|
||||
Covers: config.py:109
|
||||
"""
|
||||
|
||||
def test_secret_key_too_short_rejected(self):
|
||||
"""
|
||||
Test that SECRET_KEY shorter than 32 characters is rejected.
|
||||
|
||||
Security Risk:
|
||||
Short keys (e.g., "password123") can be brute-forced, allowing
|
||||
attackers to forge JWT tokens.
|
||||
|
||||
Covers line 109.
|
||||
"""
|
||||
# Save original SECRET_KEY
|
||||
original_secret = os.environ.get("SECRET_KEY")
|
||||
|
||||
try:
|
||||
# Try to set a short SECRET_KEY (only 20 characters)
|
||||
short_key = "a" * 20 # Too short!
|
||||
os.environ["SECRET_KEY"] = short_key
|
||||
|
||||
# Import Settings class fresh (to pick up new env var)
|
||||
# The ValidationError should be raised during reload when Settings() is instantiated
|
||||
import importlib
|
||||
from app.core import config
|
||||
|
||||
# Reload will raise ValidationError because Settings() is instantiated at module level
|
||||
with pytest.raises(ValidationError, match="at least 32 characters"):
|
||||
importlib.reload(config)
|
||||
|
||||
finally:
|
||||
# Restore original SECRET_KEY
|
||||
if original_secret:
|
||||
os.environ["SECRET_KEY"] = original_secret
|
||||
else:
|
||||
os.environ.pop("SECRET_KEY", None)
|
||||
|
||||
# Reload config to restore original settings
|
||||
import importlib
|
||||
from app.core import config
|
||||
importlib.reload(config)
|
||||
|
||||
def test_secret_key_exactly_32_characters_accepted(self):
|
||||
"""
|
||||
Test that SECRET_KEY with exactly 32 characters is accepted.
|
||||
|
||||
Minimum secure length.
|
||||
"""
|
||||
original_secret = os.environ.get("SECRET_KEY")
|
||||
|
||||
try:
|
||||
# Set exactly 32-character key
|
||||
key_32 = "a" * 32
|
||||
os.environ["SECRET_KEY"] = key_32
|
||||
|
||||
import importlib
|
||||
from app.core import config
|
||||
importlib.reload(config)
|
||||
|
||||
# Should work
|
||||
settings = config.Settings()
|
||||
assert len(settings.SECRET_KEY) == 32
|
||||
|
||||
finally:
|
||||
if original_secret:
|
||||
os.environ["SECRET_KEY"] = original_secret
|
||||
else:
|
||||
os.environ.pop("SECRET_KEY", None)
|
||||
|
||||
import importlib
|
||||
from app.core import config
|
||||
importlib.reload(config)
|
||||
|
||||
def test_secret_key_long_enough_accepted(self):
|
||||
"""
|
||||
Test that SECRET_KEY with 32+ characters is accepted.
|
||||
|
||||
Sanity check that valid keys work.
|
||||
"""
|
||||
original_secret = os.environ.get("SECRET_KEY")
|
||||
|
||||
try:
|
||||
# Set long key (64 characters)
|
||||
key_64 = "a" * 64
|
||||
os.environ["SECRET_KEY"] = key_64
|
||||
|
||||
import importlib
|
||||
from app.core import config
|
||||
importlib.reload(config)
|
||||
|
||||
# Should work
|
||||
settings = config.Settings()
|
||||
assert len(settings.SECRET_KEY) >= 32
|
||||
|
||||
finally:
|
||||
if original_secret:
|
||||
os.environ["SECRET_KEY"] = original_secret
|
||||
else:
|
||||
os.environ.pop("SECRET_KEY", None)
|
||||
|
||||
import importlib
|
||||
from app.core import config
|
||||
importlib.reload(config)
|
||||
|
||||
def test_default_secret_key_meets_requirements(self):
|
||||
"""
|
||||
Test that the default SECRET_KEY (if no env var) meets requirements.
|
||||
|
||||
Ensures our defaults are secure.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
# Current settings should have valid SECRET_KEY
|
||||
assert len(settings.SECRET_KEY) >= 32, "Default SECRET_KEY must be at least 32 chars"
|
||||
Reference in New Issue
Block a user