Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 129 additions & 69 deletions SOURCE_MANIFEST.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions docs/KNOWN_LIMITATIONS.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ THOTH는 실험 단계의 소스 미리보기입니다. 이 문서는 저장된

English: [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md)

## 로컬 모드는 내 컴퓨터의 사용자를 신뢰합니다

로컬 모드는 한 사람이 사용하는 환경을 전제로, 같은 컴퓨터의 프로그램을 별도 인증 없이 신뢰합니다. 서버는 로컬 Host 이름을 확인하고, 상태를 바꿀 수 있는 요청에 Origin이 있으면 로컬 HTTP 출처만 허용합니다. RPC와 취소 요청은 JSON 본문이어야 합니다. 다른 Host·Origin이나 평문 RPC 요청은 거절하며 내장 화면, Vite 개발 프록시, 로컬 JSON 클라이언트는 그대로 사용할 수 있습니다. 이 검사는 인증을 대신하지 않습니다. 같은 컴퓨터의 다른 프로그램은 Origin을 생략하고 허용된 헤더를 보낼 수 있으므로 막지 못합니다. 심사용(hosted review) 모드는 별도 접근 통제를 사용합니다.

## 작업 공간마다 THOTH는 하나만 실행됩니다

작업 공간 하나는 한 번에 THOTH 프로세스 하나만 사용합니다. 같은 작업 공간에서 두 번째를
Expand Down
4 changes: 4 additions & 0 deletions docs/KNOWN_LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ verification state is in [VERIFICATION.md](VERIFICATION.md).

한국어 안내: [KNOWN_LIMITATIONS.ko.md](KNOWN_LIMITATIONS.ko.md)

## Local access trusts the user of this computer

Local mode assumes one user and trusts programs on the same computer without authenticating them. The server checks local Host names and, for requests that can change state, accepts only local HTTP Origins when an Origin is present; RPC and cancellation requests also require JSON. This rejects requests with foreign Host/Origin values and plain-text RPC bodies while preserving the built-in UI, the Vite development proxy and local JSON clients. These checks are not authentication: another program on this computer can omit Origin and send allowed headers. Hosted review has separate access controls.

## One THOTH process per workspace is enforced

A workspace is open in one THOTH process at a time. Starting a second one on the same workspace
Expand Down
2 changes: 1 addition & 1 deletion migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
fileConfig(config.config_file_name, disable_existing_loggers=False)

override_url = os.getenv("THOTH_ALEMBIC_URL")
if override_url:
Expand Down
323 changes: 162 additions & 161 deletions src/thoth/adapters/connectors/anonymous_browser.py

Large diffs are not rendered by default.

99 changes: 99 additions & 0 deletions src/thoth/adapters/connectors/anonymous_browser_lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Own one render's browser and driver, independently of its reading deadline."""

from __future__ import annotations

import asyncio
import logging
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING

from thoth.domain.connectors import ConnectorErrorCode, ConnectorFailure

if TYPE_CHECKING:
from playwright.async_api import Browser

_log = logging.getLogger(__name__)
_pending: set[asyncio.Task[None]] = set()


def _owned_task(action: Awaitable[None]) -> asyncio.Task[None]:
async def invoke() -> None:
await action

task = asyncio.create_task(invoke())
_pending.add(task)

def finished(done: asyncio.Task[None]) -> None:
_pending.discard(done)
if not done.cancelled():
done.exception()

task.add_done_callback(finished)
return task


class AnonymousBrowserLifecycle:
def __init__(self) -> None:
self.browser: Browser | None = None
self._stop_driver: Callable[[], Awaitable[None]] | None = None

async def launch(self, timeout: float) -> Browser:
try:
from playwright.async_api import async_playwright
except ImportError as exc:
raise ConnectorFailure(
ConnectorErrorCode.DRIVER_ERROR, "ANONYMOUS_BROWSER_NOT_INSTALLED"
) from exc
manager = async_playwright()
self._stop_driver = manager.__aexit__
driver = await manager.start()
self.browser = await driver.chromium.launch(
headless=True,
timeout=timeout * 1000,
chromium_sandbox=True,
args=["--disable-background-networking", "--disable-quic", "--no-proxy-server"],
)
return self.browser

async def close(self) -> None:
"""Finish owned cleanup before propagating any caller cancellation."""
cleanup = _owned_task(self._cleanup())
cancelled = False
while True:
try:
await asyncio.shield(cleanup)
break
except asyncio.CancelledError:
if cleanup.cancelled():
raise
cancelled = True
except Exception:
if cancelled:
raise asyncio.CancelledError from None
raise
if cancelled:
raise asyncio.CancelledError

async def _cleanup(self) -> None:
if self._stop_driver is None:
return
closing = None if self.browser is None else _owned_task(self.browser.close())
if closing is not None:
done, _ = await asyncio.wait({closing}, timeout=2)
if not done:
_log.warning("ANONYMOUS_BROWSER_CLOSE_DELAYED")
elif closing.cancelled() or closing.exception() is not None:
_log.warning("ANONYMOUS_BROWSER_CLOSE_FAILED")
# Closing this call's driver pipe re-enters SDK browser cleanup and forces
# a browser whose graceful close is already in progress to exit.
stopping = _owned_task(self._stop_driver())
done, _ = await asyncio.wait({stopping}, timeout=2)
confirmed = bool(done) and not stopping.cancelled() and stopping.exception() is None
for task in (closing, stopping):
if task is not None and not task.done():
task.cancel()
if not confirmed:
_log.error("ANONYMOUS_BROWSER_CLEANUP_UNCONFIRMED")
raise ConnectorFailure(
ConnectorErrorCode.DRIVER_ERROR, "ANONYMOUS_BROWSER_CLEANUP_UNCONFIRMED"
)
11 changes: 5 additions & 6 deletions src/thoth/adapters/http/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
is_hosted_credential_method,
)
from thoth.adapters.http.internal_routes import mount_internal_routes
from thoth.adapters.http.local_request_guard import LocalRequestGuard
from thoth.adapters.storage.workspace_owner import WorkspaceInUse
from thoth.apps.hosted_review_dispatch import hosted_dispatch
from thoth.apps.hosted_review_snapshot import (
Expand Down Expand Up @@ -217,6 +218,8 @@ async def lifespan(application: FastAPI) -> AsyncGenerator[None]:
version=__version__,
lifespan=lifespan,
)
if not hosted_mode():
application.add_middleware(LocalRequestGuard)
application.state.bus = bus
application.state.runtime = None
application.state.session_runtimes = {}
Expand Down Expand Up @@ -309,7 +312,6 @@ async def stage_file(request: Request, file: UploadFile = File(...)) -> dict[str
return application



def _mount_async_rpc_route(
application: FastAPI, auth: HttpAuthenticationPort | None, local_actor_id: str
) -> None:
Expand Down Expand Up @@ -337,13 +339,10 @@ def _mount_internal_routes(application: FastAPI) -> None:
_resolve_bus_for_request=lambda app, request: _resolve_bus_for_request(app, request),
)


def web_ui_dist() -> Path | None:
raw = os.environ.get("THOTH_WEB_DIST")
dist = (
Path(raw)
if raw
else Path(__file__).resolve().parents[4] / "apps" / "web" / "dist"
)
dist = Path(raw) if raw else Path(__file__).resolve().parents[4] / "apps" / "web" / "dist"
if (dist / "index.html").is_file():
return dist
return None
Expand Down
125 changes: 125 additions & 0 deletions src/thoth/adapters/http/local_request_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Reject nonlocal request origins before body, runtime or storage access."""

from __future__ import annotations

import json
import re
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Literal
from urllib.parse import urlsplit

from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send

from thoth.adapters.http.hosted_review import hosted_mode

_NAMES = frozenset({"127.0.0.1", "localhost", "::1"})
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_JSON_PATHS = frozenset({"/rpc", "/rpc/query", "/rpc/async"})
_CANCEL_PATH = re.compile(r"/operations/[^/]+/cancel")


@dataclass(frozen=True)
class LocalRequestRejection:
status: int
code: Literal["LOCAL_HOST_REJECTED", "LOCAL_ORIGIN_REJECTED", "JSON_CONTENT_TYPE_REQUIRED"]
message_ko: str
message_en: str

def payload(self) -> dict[str, str]:
return {"code": self.code, "message_ko": self.message_ko, "message_en": self.message_en}


_HOST = LocalRequestRejection(403, "LOCAL_HOST_REJECTED", "호스트 거부", "Host rejected")
_ORIGIN = LocalRequestRejection(403, "LOCAL_ORIGIN_REJECTED", "출처 거부", "Origin rejected")
_JSON = LocalRequestRejection(415, "JSON_CONTENT_TYPE_REQUIRED", "JSON 필요", "JSON required")


def _header_values(headers: Sequence[tuple[bytes, bytes]], name: bytes) -> list[str]:
return [value.decode("latin-1") for key, value in headers if key.lower() == name]


def _local_authority(value: str, *, origin: bool) -> bool:
if not value or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value):
return False
try:
parsed = urlsplit(value if origin else "//" + value)
if origin and parsed.scheme.lower() != "http":
return False
if (
parsed.hostname not in _NAMES
or parsed.username is not None
or parsed.password is not None
or parsed.path
or parsed.query
or parsed.fragment
or "?" in value
or "#" in value
or "\\" in value
):
return False
# Validate port syntax without restricting the server or proxy's port.
_ = parsed.port
authority = parsed.netloc
if authority.endswith(":"):
return False
return parsed.hostname != "::1" or authority == "[::1]" or authority.startswith("[::1]:")
except ValueError:
return False


def check_local_request(
method: str,
path: str,
headers: Sequence[tuple[bytes, bytes]],
*,
websocket: bool = False,
) -> LocalRequestRejection | None:
hosts = _header_values(headers, b"host")
if len(hosts) != 1 or not _local_authority(hosts[0], origin=False):
return _HOST
if not websocket and method.upper() in _SAFE_METHODS:
return None
origins = _header_values(headers, b"origin")
if origins and (len(origins) != 1 or not _local_authority(origins[0], origin=True)):
return _ORIGIN
normalized = path.rstrip("/")
if not websocket and (normalized in _JSON_PATHS or _CANCEL_PATH.fullmatch(normalized)):
types = _header_values(headers, b"content-type")
if len(types) != 1 or types[0].split(";", 1)[0].strip().lower() != "application/json":
return _JSON
return None


class LocalRequestGuard:
def __init__(self, app: ASGIApp) -> None:
self.app = app

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
kind = scope["type"]
if kind not in {"http", "websocket"} or hosted_mode():
await self.app(scope, receive, send)
return
rejection = check_local_request(
scope.get("method", "GET"),
scope["path"],
scope["headers"],
websocket=kind == "websocket",
)
if rejection is None:
await self.app(scope, receive, send)
elif kind == "websocket":
await send(
{
"type": "websocket.close",
"code": 1008,
"reason": json.dumps(
rejection.payload(), ensure_ascii=False, separators=(",", ":")
),
}
)
else:
await JSONResponse(rejection.payload(), status_code=rejection.status)(
scope, receive, send
)
31 changes: 21 additions & 10 deletions src/thoth/adapters/storage/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,7 @@ def read_authorization(
action_authorizations.c.project_id == project_id,
action_authorizations.c.authorization_id == authorization_id,
)
.order_by(
action_authorizations.c.created_at.desc(),
literal_column("rowid", Integer()).desc(),
)
.order_by(literal_column("rowid", Integer()).desc())
.limit(1)
)
.mappings()
Expand All @@ -320,9 +317,7 @@ def list_authorizations(
statement = statement.where(action_authorizations.c.plan_id == plan_id)
with read_connection(self._engine) as connection:
rows = connection.execute(
statement.order_by(
action_authorizations.c.created_at, literal_column("rowid", Integer())
)
statement.order_by(literal_column("rowid", Integer()))
).mappings()
values = tuple(
AuthorizationEnvelopeRecord.model_validate(load(row["content_json"]))
Expand All @@ -338,6 +333,22 @@ def list_authorizations(
or self._resource_access.may_read_revision(project_id, latest[key].plan_revision_digest)
)

def has_authorization_successor(
self, project_id: str, authorization_id: str, revision_digest: str
) -> bool:
with read_connection(self._engine) as connection:
rows = connection.execute(
select(action_authorizations.c.content_json).where(
action_authorizations.c.project_id == project_id,
action_authorizations.c.authorization_id == authorization_id,
)
).scalars()
return any(
AuthorizationEnvelopeRecord.model_validate(load(row)).supersedes_revision_digest
== revision_digest
for row in rows
)

def append_audit(self, value: ActionAuditRecord) -> None:
with write_connection(self._engine) as connection:
connection.execute(
Expand Down Expand Up @@ -374,9 +385,9 @@ def list_audit(self, project_id: str, subject_id: str | None) -> tuple[ActionAud
)
if self._resource_access is None:
return values
if self._ledger is None:
return ()
heads = self._ledger.read_heads(project_id)
if self._ledger is None:
return ()
heads = self._ledger.read_heads(project_id)
return tuple(
item
for item in values
Expand Down
Loading
Loading