Files
DECNET/tests/api/auth/test_password_policy.py
anti 245975a6dd fix(security): close LOW ASVS findings — env bypass, SSE/deployment authz, CN fail-close, password byte-limit, exception leaks, BUG-12..16
Auth/session (V2.1.7, V4.1.5, V4.1.6, V2.1.4/V2.1.5):
- env secret validation no longer bypassed by attacker-injectable PYTEST* env;
  gated on explicit DECNET_TESTING=1 (set only in conftest).
- must_change_password now enforced on the SSE header-JWT path, not just ticket mint.
- GET /system/deployment-mode requires viewer auth (was leaking role + topology size).
- CreateUser/ResetUser passwords min_length=12; passwords >72 bytes rejected
  explicitly instead of bcrypt silently truncating.

Swarm ingestion (V9.1.3, BUG-16):
- Log listener hard-rejects peers with unparseable/empty cert CN (fail closed,
  ingests nothing) instead of tagging 'unknown'.
- Shutdown handlers no longer swallow real errors (narrowed to CancelledError).

Info leakage (V7.1.2, V14.1.2):
- Exception text sanitized on swarm-update, health, tarpit, realism, file-drop,
  blank-topology endpoints (raw tc/docker stderr, DB/Docker errors logged
  server-side, generic detail returned). pyproject license corrected to AGPL-3.0.

Correctness (BUG-12..16):
- BUG-12 atomic credential upsert (UNIQUE constraint + IntegrityError retry,
  consistent principal_key canonicalization).
- BUG-13 rule-tail watermark uses >= with seen-id dedup (no same-second drop).
- BUG-14 worker wake cleared before wait (no lost wake during tick).
- BUG-15 intel gather tolerates an unexpected provider raise.
- BUG-16 see above.

Already-closed (verified, no change): V2.1.6, V5.1.3, V9.1.2. Accept-risk +
documented: V2.1.8 cache window, V3.1.3 idle timeout. Tests added for every fix;
unanimous adversarial review after two refute-fix rounds.
2026-06-10 13:27:14 -04:00

207 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: AGPL-3.0-or-later
"""V2.1.4 + V2.1.5 password policy tests.
Covers:
- min_length=12 enforced on CreateUserRequest and ResetUserPasswordRequest
- bcrypt 72-byte limit: multi-byte passwords >72 bytes are rejected (not silently truncated)
"""
import pytest
import httpx
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
# '€' (U+20AC) encodes to 3 UTF-8 bytes.
# 25 × 3 = 75 bytes > 72 — valid char count, over the byte cap.
_OVER_72_BYTES: str = "" * 25
# Exactly at the limit: 24 × 3 = 72 bytes — must be ACCEPTED.
_EXACTLY_72_BYTES: str = "" * 24
# ─── V2.1.4: min_length=12 on create ────────────────────────────────────────
@pytest.mark.anyio
async def test_create_user_11char_password_rejected(
client: httpx.AsyncClient, auth_token: str
) -> None:
"""11-character password must be rejected on user creation (min is 12)."""
resp = await client.post(
"/api/v1/config/users",
json={"username": "shortpwduser", "password": "short11char", "role": "viewer"},
headers={"Authorization": f"Bearer {auth_token}"},
)
# Schema-guard middleware may surface as 400; FastAPI validation as 422.
assert resp.status_code in (400, 422), (
f"Expected 400/422 for 11-char password on create, got {resp.status_code}"
)
@pytest.mark.anyio
async def test_create_user_12char_password_accepted(
client: httpx.AsyncClient, auth_token: str
) -> None:
"""Exactly 12-character ASCII password must be accepted."""
resp = await client.post(
"/api/v1/config/users",
json={"username": "minpwduser12", "password": "exactly12chr", "role": "viewer"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 200, (
f"Expected 200 for 12-char password on create, got {resp.status_code}: {resp.text}"
)
# ─── V2.1.4: min_length=12 on reset ─────────────────────────────────────────
@pytest.mark.anyio
async def test_reset_password_11char_rejected(
client: httpx.AsyncClient, auth_token: str
) -> None:
"""11-character new_password must be rejected on admin password reset."""
# Create a user to reset
create_resp = await client.post(
"/api/v1/config/users",
json={"username": "resetpolicy1", "password": "securepass123", "role": "viewer"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert create_resp.status_code == 200
user_uuid = create_resp.json()["uuid"]
resp = await client.put(
f"/api/v1/config/users/{user_uuid}/reset-password",
json={"new_password": "short11char"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code in (400, 422), (
f"Expected 400/422 for 11-char password on reset, got {resp.status_code}"
)
@pytest.mark.anyio
async def test_reset_password_12char_accepted(
client: httpx.AsyncClient, auth_token: str
) -> None:
"""Exactly 12-character password must be accepted on admin password reset."""
create_resp = await client.post(
"/api/v1/config/users",
json={"username": "resetpolicy2", "password": "securepass123", "role": "viewer"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert create_resp.status_code == 200
user_uuid = create_resp.json()["uuid"]
resp = await client.put(
f"/api/v1/config/users/{user_uuid}/reset-password",
json={"new_password": "exactly12chr"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 200, (
f"Expected 200 for 12-char password on reset, got {resp.status_code}: {resp.text}"
)
# ─── V2.1.5: bcrypt 72-byte rejection on create ─────────────────────────────
@pytest.mark.anyio
async def test_create_user_over_72_bytes_rejected(
client: httpx.AsyncClient, auth_token: str
) -> None:
"""Password >72 UTF-8 bytes must be rejected on create (bcrypt truncation guard)."""
resp = await client.post(
"/api/v1/config/users",
json={"username": "bytepolicyusr", "password": _OVER_72_BYTES, "role": "viewer"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code in (400, 422), (
f"Expected 400/422 for >{72}-byte password on create, got {resp.status_code}"
)
@pytest.mark.anyio
async def test_create_user_exactly_72_bytes_accepted(
client: httpx.AsyncClient, auth_token: str
) -> None:
"""Password of exactly 72 UTF-8 bytes must be accepted (at the limit, not over)."""
resp = await client.post(
"/api/v1/config/users",
json={"username": "byteedgeuser1", "password": _EXACTLY_72_BYTES, "role": "viewer"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code == 200, (
f"Expected 200 for exactly-72-byte password on create, got {resp.status_code}: {resp.text}"
)
# ─── V2.1.5: bcrypt 72-byte rejection on reset ──────────────────────────────
@pytest.mark.anyio
async def test_reset_password_over_72_bytes_rejected(
client: httpx.AsyncClient, auth_token: str
) -> None:
"""new_password >72 UTF-8 bytes must be rejected on admin password reset."""
create_resp = await client.post(
"/api/v1/config/users",
json={"username": "byteresetuser", "password": "securepass123", "role": "viewer"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert create_resp.status_code == 200
user_uuid = create_resp.json()["uuid"]
resp = await client.put(
f"/api/v1/config/users/{user_uuid}/reset-password",
json={"new_password": _OVER_72_BYTES},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert resp.status_code in (400, 422), (
f"Expected 400/422 for >{72}-byte new_password on reset, got {resp.status_code}"
)
# ─── V2.1.5: bcrypt 72-byte rejection on change-password ────────────────────
@pytest.mark.anyio
async def test_change_password_new_over_72_bytes_rejected(
client: httpx.AsyncClient,
) -> None:
"""new_password >72 UTF-8 bytes must be rejected on change-password."""
login_resp = await client.post(
"/api/v1/auth/login",
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD},
)
token = login_resp.json()["access_token"]
resp = await client.post(
"/api/v1/auth/change-password",
json={"old_password": DECNET_ADMIN_PASSWORD, "new_password": _OVER_72_BYTES},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code in (400, 422), (
f"Expected 400/422 for >{72}-byte new_password on change-password, got {resp.status_code}"
)
@pytest.mark.anyio
async def test_change_password_old_over_72_bytes_rejected(
client: httpx.AsyncClient,
) -> None:
"""old_password >72 UTF-8 bytes must be rejected (no point checking against hash)."""
login_resp = await client.post(
"/api/v1/auth/login",
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD},
)
token = login_resp.json()["access_token"]
resp = await client.post(
"/api/v1/auth/change-password",
json={"old_password": _OVER_72_BYTES, "new_password": "new_secure_password"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code in (400, 422), (
f"Expected 400/422 for >{72}-byte old_password on change-password, got {resp.status_code}"
)