testy
This commit is contained in:
parent
ba0bb1c8b9
commit
a808714876
598
test/test_security.py
Normal file
598
test/test_security.py
Normal file
@ -0,0 +1,598 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.main as main
|
||||||
|
import app.routes as routes
|
||||||
|
import app.security as security
|
||||||
|
|
||||||
|
|
||||||
|
WEBHOOK_SECRET = "w" * 64
|
||||||
|
SYNC_API_KEY = "s" * 64
|
||||||
|
SEARCH_API_KEY = "a" * 64
|
||||||
|
|
||||||
|
EXPECTED_REPOSITORY = (
|
||||||
|
"KEMT/zpwiki"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sign(
|
||||||
|
body: bytes,
|
||||||
|
) -> str:
|
||||||
|
return hmac.new(
|
||||||
|
WEBHOOK_SECRET.encode(
|
||||||
|
"utf-8"
|
||||||
|
),
|
||||||
|
body,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(
|
||||||
|
security_environment,
|
||||||
|
) -> TestClient:
|
||||||
|
with TestClient(
|
||||||
|
main.app
|
||||||
|
) as test_client:
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_configuration_accepts_valid_configuration(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"WEBHOOK_SECRET",
|
||||||
|
WEBHOOK_SECRET,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"SYNC_API_KEY",
|
||||||
|
SYNC_API_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"SEARCH_API_KEY",
|
||||||
|
SEARCH_API_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"EXPECTED_GITEA_REPOSITORY",
|
||||||
|
EXPECTED_REPOSITORY,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"WEBHOOK_PULL_GIT",
|
||||||
|
"false",
|
||||||
|
)
|
||||||
|
|
||||||
|
security.validate_security_configuration()
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_configuration_rejects_shared_secrets(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
shared = "x" * 64
|
||||||
|
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"WEBHOOK_SECRET",
|
||||||
|
shared,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"SYNC_API_KEY",
|
||||||
|
shared,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"SEARCH_API_KEY",
|
||||||
|
SEARCH_API_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"EXPECTED_GITEA_REPOSITORY",
|
||||||
|
EXPECTED_REPOSITORY,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
RuntimeError,
|
||||||
|
match="navzájom rozdielne",
|
||||||
|
):
|
||||||
|
security.validate_security_configuration()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value",
|
||||||
|
[
|
||||||
|
"zpwiki",
|
||||||
|
"/zpwiki",
|
||||||
|
"KEMT/",
|
||||||
|
"KEMT/zpwiki/extra",
|
||||||
|
"KEMT /zpwiki",
|
||||||
|
"KEMT/zp wiki",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_expected_repository_rejects_invalid_format(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
value: str,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"EXPECTED_GITEA_REPOSITORY",
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
RuntimeError,
|
||||||
|
match="vlastník/repozitár",
|
||||||
|
):
|
||||||
|
security.expected_gitea_repository()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
(
|
||||||
|
"value",
|
||||||
|
"expected",
|
||||||
|
),
|
||||||
|
[
|
||||||
|
("true", True),
|
||||||
|
("TRUE", True),
|
||||||
|
("1", True),
|
||||||
|
("yes", True),
|
||||||
|
("on", True),
|
||||||
|
("false", False),
|
||||||
|
("FALSE", False),
|
||||||
|
("0", False),
|
||||||
|
("no", False),
|
||||||
|
("off", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_webhook_pull_git_boolean_values(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
value: str,
|
||||||
|
expected: bool,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"WEBHOOK_PULL_GIT",
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
security.webhook_should_pull_git()
|
||||||
|
is expected
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_pull_git_rejects_invalid_value(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"WEBHOOK_PULL_GIT",
|
||||||
|
"maybe",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
RuntimeError,
|
||||||
|
match="boolean",
|
||||||
|
):
|
||||||
|
security.webhook_should_pull_git()
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_payload_requires_full_name() -> None:
|
||||||
|
payload = {
|
||||||
|
"repository": {
|
||||||
|
"name": "zpwiki",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert (
|
||||||
|
security.repository_name_from_payload(
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_payload_accepts_full_name() -> None:
|
||||||
|
payload = {
|
||||||
|
"repository": {
|
||||||
|
"full_name": (
|
||||||
|
"KEMT/zpwiki"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert (
|
||||||
|
security.repository_name_from_payload(
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
== "KEMT/zpwiki"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_comparison_is_case_insensitive() -> None:
|
||||||
|
assert (
|
||||||
|
security.same_repository(
|
||||||
|
"kemt/ZPWIKI",
|
||||||
|
"KEMT/zpwiki",
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_signature_accepts_valid_digest() -> None:
|
||||||
|
body = b'{"test":true}'
|
||||||
|
|
||||||
|
signature = sign(
|
||||||
|
body
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
security.verify_gitea_signature(
|
||||||
|
body,
|
||||||
|
signature,
|
||||||
|
WEBHOOK_SECRET,
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_signature_accepts_sha256_prefix() -> None:
|
||||||
|
body = b'{"test":true}'
|
||||||
|
|
||||||
|
signature = (
|
||||||
|
"sha256="
|
||||||
|
+ sign(
|
||||||
|
body
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
security.verify_gitea_signature(
|
||||||
|
body,
|
||||||
|
signature,
|
||||||
|
WEBHOOK_SECRET,
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_signature_rejects_modified_body() -> None:
|
||||||
|
original = b'{"test":true}'
|
||||||
|
|
||||||
|
modified = b'{"test":false}'
|
||||||
|
|
||||||
|
signature = sign(
|
||||||
|
original
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
security.verify_gitea_signature(
|
||||||
|
modified,
|
||||||
|
signature,
|
||||||
|
WEBHOOK_SECRET,
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"signature",
|
||||||
|
[
|
||||||
|
None,
|
||||||
|
"",
|
||||||
|
"abc",
|
||||||
|
"x" * 64,
|
||||||
|
"0" * 63,
|
||||||
|
"0" * 65,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_gitea_signature_rejects_invalid_signature(
|
||||||
|
signature: str | None,
|
||||||
|
) -> None:
|
||||||
|
assert (
|
||||||
|
security.verify_gitea_signature(
|
||||||
|
b"body",
|
||||||
|
signature,
|
||||||
|
WEBHOOK_SECRET,
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_key_cannot_access_rag(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/rag",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SYNC_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": "Ján Holp",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_key_cannot_access_sync(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/sync",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": (
|
||||||
|
SEARCH_API_KEY
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"pull_git": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_key_cannot_be_used_as_rag_bearer(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/rag",
|
||||||
|
headers={
|
||||||
|
"Authorization": (
|
||||||
|
f"Bearer {SYNC_API_KEY}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": "Ján Holp",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_does_not_expose_filesystem_paths(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.get(
|
||||||
|
"/health"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"database_path"
|
||||||
|
not in payload
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"zpwiki_root"
|
||||||
|
not in payload
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"/home/"
|
||||||
|
not in response.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_swagger_docs_are_disabled(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
assert (
|
||||||
|
client.get(
|
||||||
|
"/docs"
|
||||||
|
).status_code
|
||||||
|
== 404
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
client.get(
|
||||||
|
"/redoc"
|
||||||
|
).status_code
|
||||||
|
== 404
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_schema_remains_available(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.get(
|
||||||
|
"/openapi.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"/rag"
|
||||||
|
in response.json()[
|
||||||
|
"paths"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_response_headers(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.get(
|
||||||
|
"/health"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.headers[
|
||||||
|
"cache-control"
|
||||||
|
]
|
||||||
|
== "no-store"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.headers[
|
||||||
|
"x-content-type-options"
|
||||||
|
]
|
||||||
|
== "nosniff"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.headers[
|
||||||
|
"referrer-policy"
|
||||||
|
]
|
||||||
|
== "no-referrer"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.headers[
|
||||||
|
"x-frame-options"
|
||||||
|
]
|
||||||
|
== "DENY"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_accepts_openwebui_origin(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.options(
|
||||||
|
"/rag",
|
||||||
|
headers={
|
||||||
|
"Origin": (
|
||||||
|
"https://ui.tukekemt.xyz"
|
||||||
|
),
|
||||||
|
"Access-Control-Request-Method": (
|
||||||
|
"POST"
|
||||||
|
),
|
||||||
|
"Access-Control-Request-Headers": (
|
||||||
|
"authorization,"
|
||||||
|
"content-type,"
|
||||||
|
"x-api-key"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.headers[
|
||||||
|
"access-control-allow-origin"
|
||||||
|
] == (
|
||||||
|
"https://ui.tukekemt.xyz"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_rejects_unknown_origin(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
response = client.options(
|
||||||
|
"/rag",
|
||||||
|
headers={
|
||||||
|
"Origin": (
|
||||||
|
"https://evil.example"
|
||||||
|
),
|
||||||
|
"Access-Control-Request-Method": (
|
||||||
|
"POST"
|
||||||
|
),
|
||||||
|
"Access-Control-Request-Headers": (
|
||||||
|
"authorization"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.headers.get(
|
||||||
|
"access-control-allow-origin"
|
||||||
|
)
|
||||||
|
!= "https://evil.example"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_rejects_oversized_payload(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
body = (
|
||||||
|
b"x"
|
||||||
|
* (
|
||||||
|
routes.MAX_WEBHOOK_BODY_BYTES
|
||||||
|
+ 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/webhook/gitea",
|
||||||
|
content=body,
|
||||||
|
headers={
|
||||||
|
"Content-Type": (
|
||||||
|
"application/json"
|
||||||
|
),
|
||||||
|
"X-Gitea-Event": (
|
||||||
|
"push"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 413
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_requires_repository_full_name(
|
||||||
|
client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"repository": {
|
||||||
|
"name": (
|
||||||
|
"zpwiki"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/webhook/gitea",
|
||||||
|
content=body,
|
||||||
|
headers={
|
||||||
|
"Content-Type": (
|
||||||
|
"application/json"
|
||||||
|
),
|
||||||
|
"X-Gitea-Event": (
|
||||||
|
"push"
|
||||||
|
),
|
||||||
|
"X-Gitea-Signature": (
|
||||||
|
sign(
|
||||||
|
body
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
response.status_code
|
||||||
|
== 400
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user