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