fix: refuse a token store directory owned by another user or left writable (SEC-13)
This commit is contained in:
1 parent
d96c3ac604
commit
6f7a3c0e5b
2 files changed
+252
-4
No files matched your search
@@ -0,0 +1,221 @@
|
||||
"""The encrypted OAuth token store: permissions, encryption, key handling and ownership (SEC-13)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from pytest_httpx import HTTPXMock
|
||||
|
||||
from aegis_gitea_mcp import transports
|
||||
from aegis_gitea_mcp.oauth.store import (
|
||||
STORE_FILENAME,
|
||||
OAuthStore,
|
||||
OAuthStoreError,
|
||||
SessionRecord,
|
||||
derive_fernet_key,
|
||||
token_hash,
|
||||
)
|
||||
from aegis_gitea_mcp.transports import run_http
|
||||
from tests.test_oauth_helpers import (
|
||||
APP_CLIENT_SECRET,
|
||||
ENCRYPTION_KEY,
|
||||
USER_REFRESH,
|
||||
USER_TOKEN,
|
||||
browser,
|
||||
build_oauth_app,
|
||||
oauth_settings,
|
||||
sign_in,
|
||||
)
|
||||
|
||||
OTHER_KEY = "another-key-0123456789abcdef0123456789"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _env(mock_env: None) -> None:
|
||||
"""The audit logger reads the process-wide settings."""
|
||||
|
||||
|
||||
def a_session() -> SessionRecord:
|
||||
return SessionRecord(
|
||||
session_id="s1",
|
||||
client_id="c1",
|
||||
login="alice",
|
||||
is_admin=False,
|
||||
gitea_access_token=USER_TOKEN,
|
||||
gitea_refresh_token=USER_REFRESH,
|
||||
gitea_expires_at=None,
|
||||
scopes=["gitea"],
|
||||
resource="https://mcp.example.com/mcp",
|
||||
created_at=1e12,
|
||||
)
|
||||
|
||||
|
||||
async def test_file_is_0600_and_a_new_directory_is_0700(tmp_path: Path) -> None:
|
||||
previous = os.umask(0)
|
||||
try:
|
||||
directory = tmp_path / "new" / "state"
|
||||
store = OAuthStore.load(directory, ENCRYPTION_KEY)
|
||||
store.sessions["s1"] = a_session()
|
||||
await store.save()
|
||||
finally:
|
||||
os.umask(previous)
|
||||
|
||||
assert stat.S_IMODE(directory.stat().st_mode) == 0o700
|
||||
assert stat.S_IMODE((directory / STORE_FILENAME).stat().st_mode) == 0o600
|
||||
assert [entry.name for entry in directory.iterdir()] == [STORE_FILENAME]
|
||||
|
||||
|
||||
def test_a_symlinked_store_is_refused_and_its_target_untouched(tmp_path: Path) -> None:
|
||||
target = tmp_path / "elsewhere"
|
||||
target.write_bytes(b"keep me")
|
||||
(tmp_path / STORE_FILENAME).symlink_to(target)
|
||||
|
||||
with pytest.raises(OAuthStoreError, match="symlink"):
|
||||
OAuthStore.load(tmp_path, ENCRYPTION_KEY)
|
||||
assert target.read_bytes() == b"keep me"
|
||||
|
||||
|
||||
async def test_a_symlink_swapped_in_later_is_not_written_through(tmp_path: Path) -> None:
|
||||
store = OAuthStore.load(tmp_path, ENCRYPTION_KEY)
|
||||
target = tmp_path / "elsewhere"
|
||||
target.write_bytes(b"keep me")
|
||||
(tmp_path / STORE_FILENAME).symlink_to(target)
|
||||
|
||||
with pytest.raises(OAuthStoreError, match="symlink"):
|
||||
await store.save()
|
||||
assert target.read_bytes() == b"keep me"
|
||||
|
||||
|
||||
async def test_the_file_holds_no_plaintext_and_our_tokens_only_as_hashes(
|
||||
tmp_path: Path, httpx_mock: HTTPXMock
|
||||
) -> None:
|
||||
oauth = build_oauth_app(oauth_settings(data_dir=tmp_path))
|
||||
flow = sign_in(browser(oauth.app), httpx_mock, login="alice-the-user")
|
||||
|
||||
raw = (tmp_path / STORE_FILENAME).read_bytes()
|
||||
|
||||
for secret in (
|
||||
USER_TOKEN,
|
||||
USER_REFRESH,
|
||||
flow.client_secret,
|
||||
flow.tokens["access_token"],
|
||||
flow.tokens["refresh_token"],
|
||||
"alice-the-user",
|
||||
APP_CLIENT_SECRET,
|
||||
ENCRYPTION_KEY,
|
||||
):
|
||||
assert secret.encode() not in raw
|
||||
plain = Fernet(derive_fernet_key(ENCRYPTION_KEY)).decrypt(raw).decode()
|
||||
assert flow.tokens["access_token"] not in plain
|
||||
assert flow.tokens["refresh_token"] not in plain
|
||||
assert token_hash(flow.tokens["access_token"]) in plain
|
||||
assert token_hash(flow.tokens["refresh_token"]) in plain
|
||||
|
||||
|
||||
async def test_a_wrong_key_stops_startup_without_wiping_the_store(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
store = OAuthStore.load(tmp_path, ENCRYPTION_KEY)
|
||||
store.sessions["s1"] = a_session()
|
||||
await store.save()
|
||||
before = (tmp_path / STORE_FILENAME).read_bytes()
|
||||
served: list[Any] = []
|
||||
monkeypatch.setattr(transports.uvicorn, "run", lambda *a, **k: served.append(a))
|
||||
|
||||
code = run_http(oauth_settings(data_dir=tmp_path, token_encryption_key=OTHER_KEY))
|
||||
|
||||
assert code == 2 and served == []
|
||||
err = capsys.readouterr().err
|
||||
assert "TOKEN_ENCRYPTION_KEY" in err and "move the file away" in err
|
||||
assert OTHER_KEY not in err and ENCRYPTION_KEY not in err
|
||||
assert (tmp_path / STORE_FILENAME).read_bytes() == before
|
||||
|
||||
|
||||
def test_a_garbage_store_stops_startup_and_stays_on_disk(tmp_path: Path) -> None:
|
||||
(tmp_path / STORE_FILENAME).write_bytes(b"not a fernet token")
|
||||
|
||||
with pytest.raises(OAuthStoreError):
|
||||
OAuthStore.load(tmp_path, ENCRYPTION_KEY)
|
||||
assert (tmp_path / STORE_FILENAME).read_bytes() == b"not a fernet token"
|
||||
|
||||
|
||||
# SEC-13: who owns the directory
|
||||
|
||||
|
||||
def fake_owner(monkeypatch: pytest.MonkeyPatch, directory: Path) -> None:
|
||||
"""Report ``directory`` as owned by a different uid than the running process."""
|
||||
real_stat = os.stat
|
||||
|
||||
def stat_other(path: Any, *args: Any, **kwargs: Any) -> os.stat_result:
|
||||
info = real_stat(path, *args, **kwargs)
|
||||
if Path(path) == directory:
|
||||
fields = list(info)
|
||||
fields[stat.ST_UID] = info.st_uid + 1
|
||||
return os.stat_result(fields)
|
||||
return info
|
||||
|
||||
monkeypatch.setattr(os, "stat", stat_other)
|
||||
|
||||
|
||||
async def test_a_directory_owned_by_another_user_is_refused_on_load_and_save(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
directory = tmp_path / "state"
|
||||
directory.mkdir(mode=0o700)
|
||||
store = OAuthStore(directory, ENCRYPTION_KEY)
|
||||
fake_owner(monkeypatch, directory)
|
||||
|
||||
with pytest.raises(OAuthStoreError, match="owned by another user"):
|
||||
OAuthStore.load(directory, ENCRYPTION_KEY)
|
||||
with pytest.raises(OAuthStoreError, match="owned by another user"):
|
||||
await store.save()
|
||||
assert list(directory.iterdir()) == []
|
||||
|
||||
|
||||
async def test_a_group_or_world_writable_directory_that_cannot_be_tightened_is_refused(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
directory = tmp_path / "state"
|
||||
directory.mkdir()
|
||||
directory.chmod(0o777)
|
||||
|
||||
def refuse(self: Path, mode: int) -> None:
|
||||
raise PermissionError("not permitted")
|
||||
|
||||
monkeypatch.setattr(Path, "chmod", refuse)
|
||||
|
||||
with pytest.raises(OAuthStoreError, match="writable by group or others"):
|
||||
OAuthStore.load(directory, ENCRYPTION_KEY)
|
||||
with pytest.raises(OAuthStoreError, match="writable by group or others"):
|
||||
await OAuthStore(directory, ENCRYPTION_KEY).save()
|
||||
assert list(directory.iterdir()) == []
|
||||
|
||||
|
||||
async def test_a_loose_directory_we_own_is_tightened_not_refused(tmp_path: Path) -> None:
|
||||
directory = tmp_path / "state"
|
||||
directory.mkdir()
|
||||
directory.chmod(0o777)
|
||||
|
||||
await OAuthStore.load(directory, ENCRYPTION_KEY).save()
|
||||
|
||||
assert stat.S_IMODE(directory.stat().st_mode) == 0o700
|
||||
|
||||
|
||||
def test_startup_exits_2_for_a_directory_owned_by_someone_else(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
directory = tmp_path / "state"
|
||||
directory.mkdir(mode=0o700)
|
||||
fake_owner(monkeypatch, directory)
|
||||
served: list[Any] = []
|
||||
monkeypatch.setattr(transports.uvicorn, "run", lambda *a, **k: served.append(a))
|
||||
|
||||
assert run_http(oauth_settings(data_dir=directory)) == 2
|
||||
|
||||
assert served == []
|
||||
assert "owned by another user" in capsys.readouterr().err
|
||||
Reference in new issue
Block a user