Skip to content
Open
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"plugins": [
{
"name": "kbagent",
"version": "0.63.3",
"version": "0.63.4",
"source": "./plugins/kbagent",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"category": "development"
Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kbagent",
"version": "0.63.3",
"version": "0.63.4",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"author": {
"name": "Keboola",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-cli"
version = "0.63.3"
version = "0.63.4"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
10 changes: 10 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.63.4": [
"Security: `kbagent serve` now refuses to start with an unsafe CORS configuration. The app "
"sets `allow_credentials=True`, which combined with a wildcard `--cors-origin '*'` (or a "
"malformed origin) makes Starlette reflect the request `Origin` back with "
"`Access-Control-Allow-Credentials: true` -- letting any website read authenticated "
"cross-origin responses. `create_app` now validates the origins and rejects `*` and any "
"non-`scheme://host[:port]` value, surfaced as a clean `--cors-origin` usage error rather "
"than silently shipping the unsafe combination. The default (no `--cors-origin`) localhost "
"dev set is unaffected. Private advisory GHSA-5mh2-6xgr-rf89.",
],
"0.63.3": [
"Fix: `kbagent context` no longer renders API path templates as "
"`/apps/<built-in function id>/logs/tail`. `AGENT_CONTEXT` is an f-string (it "
Expand Down
20 changes: 13 additions & 7 deletions src/keboola_agent_cli/commands/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import typer

from ..constants import ENV_CONVERSATION_ID
from ..errors import ConfigError

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -211,13 +212,18 @@ def serve_command(
raise typer.Exit(code=1) from None
resolved_ui_dist = str(candidate)

app = create_app(
config_dir=config_dir,
auth_token=auth_token,
cors_origins=cors,
serve_url=serve_url,
ui_dist=resolved_ui_dist,
)
try:
app = create_app(
config_dir=config_dir,
auth_token=auth_token,
cors_origins=cors,
serve_url=serve_url,
ui_dist=resolved_ui_dist,
)
except ConfigError as exc:
# e.g. an unsafe --cors-origin (wildcard with credentials, GHSA-5mh2).
# Render as a clean CLI usage error instead of a traceback.
raise typer.BadParameter(str(exc), param_hint="--cors-origin") from None

if resolved_ui_dist:
# UI mode: the user opens the browser directly; there is no BFF to
Expand Down
53 changes: 46 additions & 7 deletions src/keboola_agent_cli/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,51 @@ def _format_error(
)


_DEFAULT_CORS_ORIGINS = (
"http://localhost:5173", # Vite dev default
"http://localhost:8000", # Node BFF
"http://127.0.0.1:5173",
"http://127.0.0.1:8000",
)


def _is_valid_cors_origin(origin: object) -> bool:
"""True if ``origin`` is a concrete ``scheme://host[:port]`` CORS origin.

Rejects ``"*"`` and any value carrying a path / query / fragment -- per the
CORS spec an Origin is scheme + host + optional port and nothing else.
"""
if not isinstance(origin, str) or origin == "*":
return False
for scheme in ("http://", "https://"):
if origin.startswith(scheme):
rest = origin[len(scheme) :]
return bool(rest) and not any(c in rest for c in "/?#")
return False


def _resolve_cors_origins(cors_origins: list[str] | None) -> list[str]:
"""Resolve CORS origins for the credentialed app, rejecting unsafe values.

The app sets ``allow_credentials=True``. Combined with a wildcard (or
otherwise malformed) origin, Starlette reflects the request ``Origin`` and
returns ``Access-Control-Allow-Credentials: true`` -- letting any website
read authenticated cross-origin responses (GHSA-5mh2-6xgr-rf89). Fail fast
rather than ship that: reject ``"*"`` and any non ``scheme://host[:port]``
origin. Default (no ``--cors-origin``) is the localhost dev set.
"""
origins = cors_origins or list(_DEFAULT_CORS_ORIGINS)
invalid = [o for o in origins if not _is_valid_cors_origin(o)]
if invalid:
raise ConfigError(
f"Refusing to start: CORS origin(s) {invalid} are unsafe with "
f"credentialed requests. Use explicit 'scheme://host[:port]' origins "
f"(e.g. http://localhost:5173); '*' is rejected because it would "
f"expose authenticated responses to any website."
)
return origins


def create_app(
*,
config_dir: str | None = None,
Expand Down Expand Up @@ -524,13 +569,7 @@ async def _lifespan(app_: FastAPI):

app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins
or [
"http://localhost:5173", # Vite dev default
"http://localhost:8000", # Node BFF
"http://127.0.0.1:5173",
"http://127.0.0.1:8000",
],
allow_origins=_resolve_cors_origins(cors_origins),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
Expand Down
46 changes: 46 additions & 0 deletions tests/test_serve_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,52 @@ def test_non_api_path_stays_public_not_auth_walled(self, tmp_path: Path, ui_dist
)


class TestCorsCredentialsGuard:
"""GHSA-5mh2-6xgr-rf89: allow_credentials=True must never pair with a
wildcard or malformed CORS origin (Starlette would reflect any Origin and
return Access-Control-Allow-Credentials: true). create_app rejects such a
config at startup so the unsafe combination can never ship."""

@pytest.mark.parametrize(
"bad",
[
["*"],
["http://localhost:5173", "*"],
["evil.com"],
["http://evil.com/path"],
["ws://x"],
],
)
def test_unsafe_cors_origins_rejected(self, tmp_path: Path, bad: list[str]) -> None:
from keboola_agent_cli.errors import ConfigError

with pytest.raises(ConfigError):
create_app(config_dir=str(tmp_path / "cfg"), auth_token="t", cors_origins=bad)

@pytest.mark.parametrize(
"good",
[
None,
["http://localhost:5173"],
["https://app.example.com", "http://127.0.0.1:8000"],
],
)
def test_safe_cors_origins_accepted(self, tmp_path: Path, good: list[str] | None) -> None:
app = create_app(config_dir=str(tmp_path / "cfg"), auth_token="t", cors_origins=good)
assert app is not None

def test_is_valid_cors_origin_predicate(self) -> None:
from keboola_agent_cli.server.app import _is_valid_cors_origin

assert _is_valid_cors_origin("http://localhost:5173")
assert _is_valid_cors_origin("https://app.example.com")
assert _is_valid_cors_origin("http://127.0.0.1:8000")
assert not _is_valid_cors_origin("*")
assert not _is_valid_cors_origin("example.com") # no scheme
assert not _is_valid_cors_origin("http://x/path") # carries a path
assert not _is_valid_cors_origin("ws://x") # wrong scheme


class TestCookieAuth:
"""Cookie path: ``GET /`` sets the cookie, subsequent requests use it.

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading