From 830569f0c509a8d37e71368a0f8f9181c5057fc7 Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:56:48 +0200 Subject: [PATCH 1/2] Validate Domain routes (S-003-1, S-003-8..12) A Domain can now declare spec.routes: proxy routes (path + upstream) and static routes (path + static component, directory, spaFallback). The schema holds each route's shape and the release-relative directory and fallback; config validate checks the route source, path syntax, reserved and duplicate paths, and that a static component exists and is deployed on the proxy server. The inventory parses routes into value objects and serializes them only when present, so a Domain with only proxy.upstream is unchanged. Rendering routes comes with #162. Fixes #159 Part of #90 Co-Authored-By: Claude Opus 5.5 --- changelog.d/159-domain-routes-validation.md | 3 + config/schemas/v1/domain.schema.json | 113 +++++-- sdk/src/cloudfall/domain.py | 70 +++++ sdk/src/cloudfall/inventory.py | 99 ++++++- sdk/src/cloudfall/validation.py | 140 +++++++++ sdk/tests/test_domain_routes.py | 308 ++++++++++++++++++++ 6 files changed, 708 insertions(+), 25 deletions(-) create mode 100644 changelog.d/159-domain-routes-validation.md create mode 100644 sdk/tests/test_domain_routes.py diff --git a/changelog.d/159-domain-routes-validation.md b/changelog.d/159-domain-routes-validation.md new file mode 100644 index 0000000..7641cbb --- /dev/null +++ b/changelog.d/159-domain-routes-validation.md @@ -0,0 +1,3 @@ +### Added + +- A Domain can declare `spec.routes`, path routes that each either proxy to an `upstream` (`address`, `port`) or serve a component's release files (`static`: `component`, `directory`, optional `spaFallback`). `cloudfall config validate` refuses a Domain with both `proxy.upstream` and `routes` (`domain_route_source_invalid`), a path that doesn't start with `/` or uses characters outside letters, digits and `._~/-`, or a static path without a trailing `/` (`domain_route_path_invalid`), a path declared twice (`domain_route_path_duplicate`), a path under `/.well-known/acme-challenge` (`domain_route_path_reserved`), a missing static component (`resource_reference_missing`) or one not deployed on the Domain's `proxy.server` (`domain_route_component_not_on_proxy`), and a `directory` or `spaFallback` that is absolute, climbs out with `..`, or, for `directory`, names the release root. Validation only: the domains playbook doesn't render routes yet and still refuses a Domain without `proxy.upstream` before any change. Domains with only `proxy.upstream` validate and render as before (#159) diff --git a/config/schemas/v1/domain.schema.json b/config/schemas/v1/domain.schema.json index 138e594..fb9f211 100644 --- a/config/schemas/v1/domain.schema.json +++ b/config/schemas/v1/domain.schema.json @@ -3,6 +3,87 @@ "$id": "https://cloudfall.internal/schemas/v1/domain.schema.json", "title": "Cloudfall public domain route", "x-cloudfall-kind": "Domain", + "$defs": { + "upstream": { + "type": "object", + "properties": { + "address": { + "type": "string", + "anyOf": [ + { + "format": "ipv4" + }, + { + "format": "ipv6" + } + ] + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "address", + "port" + ], + "additionalProperties": false + }, + "releasePath": { + "type": "string", + "pattern": "^(?!/)(?!(?:.*/)?\\.\\.(?:/|$))[A-Za-z0-9._/-]+$" + }, + "route": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "upstream": { + "$ref": "#/$defs/upstream" + }, + "static": { + "type": "object", + "properties": { + "component": { + "$ref": "common.schema.json#/$defs/resourceId" + }, + "directory": { + "$ref": "#/$defs/releasePath", + "not": { + "pattern": "^\\.(?:/+\\.)*/*$" + } + }, + "spaFallback": { + "$ref": "#/$defs/releasePath" + } + }, + "required": [ + "component", + "directory" + ], + "additionalProperties": false + } + }, + "required": [ + "path" + ], + "oneOf": [ + { + "required": [ + "upstream" + ] + }, + { + "required": [ + "static" + ] + } + ], + "additionalProperties": false + } + }, "type": "object", "properties": { "apiVersion": { @@ -42,30 +123,7 @@ "$ref": "common.schema.json#/$defs/serviceName" }, "upstream": { - "type": "object", - "properties": { - "address": { - "type": "string", - "anyOf": [ - { - "format": "ipv4" - }, - { - "format": "ipv6" - } - ] - }, - "port": { - "type": "integer", - "minimum": 1, - "maximum": 65535 - } - }, - "required": [ - "address", - "port" - ], - "additionalProperties": false + "$ref": "#/$defs/upstream" } }, "required": [ @@ -75,6 +133,13 @@ ], "additionalProperties": false }, + "routes": { + "type": "array", + "items": { + "$ref": "#/$defs/route" + }, + "minItems": 1 + }, "origin": { "type": "object", "properties": { diff --git a/sdk/src/cloudfall/domain.py b/sdk/src/cloudfall/domain.py index 10d30eb..ccf3a16 100644 --- a/sdk/src/cloudfall/domain.py +++ b/sdk/src/cloudfall/domain.py @@ -793,6 +793,76 @@ def from_boundary(cls, value: object) -> RelativeDirectory: return cls(_required_string(value, "relative directory")) +_ROUTE_PATH_PATTERN = re.compile(r"/[A-Za-z0-9._~/-]*") +_ACME_CHALLENGE_PATH = "/.well-known/acme-challenge" + + +@dataclass(frozen=True, slots=True) +class DomainRoutePath: + """URL path prefix of a Domain route, safe to render as an nginx location.""" + + value: str + + def __post_init__(self) -> None: + """Accept only ``/`` then letters, digits and ``._~/-``.""" + if not _ROUTE_PATH_PATTERN.fullmatch(self.value): + message = ( + f"invalid route path: {self.value!r} must start with '/' and use " + "only letters, digits and '._~/-'" + ) + raise ValueError(message) + + @property + def is_directory(self) -> bool: + """Return whether the path ends with ``/``, as a static route needs.""" + return self.value.endswith("/") + + @property + def is_reserved(self) -> bool: + """Return whether certificate issuance owns the path.""" + return self.value == _ACME_CHALLENGE_PATH or self.value.startswith( + f"{_ACME_CHALLENGE_PATH}/" + ) + + @classmethod + def from_boundary(cls, value: object) -> DomainRoutePath: + """Coerce a boundary value into a route path.""" + return cls(_required_string(value, "route path")) + + +_RELEASE_PATH_PATTERN = re.compile(r"[A-Za-z0-9._/-]+") + + +@dataclass(frozen=True, slots=True) +class ReleaseRelativePath: + """Path inside a component release that nginx may serve files from.""" + + value: str + + def __post_init__(self) -> None: + """Reject absolute paths, ``..`` segments and characters outside ``._-/``.""" + if ( + not _RELEASE_PATH_PATTERN.fullmatch(self.value) + or self.value.startswith("/") + or ".." in self.value.split("/") + ): + message = ( + f"invalid release path: {self.value!r} must be relative, use only " + "letters, digits and '._-/', and have no '..' segment" + ) + raise ValueError(message) + + @property + def is_release_root(self) -> bool: + """Return whether the path names the release root itself, such as ``.``.""" + return PurePosixPath(self.value) == PurePosixPath(".") + + @classmethod + def from_boundary(cls, value: object) -> ReleaseRelativePath: + """Coerce a boundary value into a release path.""" + return cls(_required_string(value, "release path")) + + _RELEASE_TIMEOUT_MINIMUM = 1 _RELEASE_TIMEOUT_MAXIMUM = 1800 _RELEASE_TIMEOUT_DEFAULT = 300 diff --git a/sdk/src/cloudfall/inventory.py b/sdk/src/cloudfall/inventory.py index 038680e..86f0940 100644 --- a/sdk/src/cloudfall/inventory.py +++ b/sdk/src/cloudfall/inventory.py @@ -23,6 +23,7 @@ DayTime, DeploymentApproval, DnsMode, + DomainRoutePath, EmailAddress, FileMode, FilesystemName, @@ -54,6 +55,7 @@ RelativeDirectory, RelativeRepositoryPath, ReleaseCommandTimeout, + ReleaseRelativePath, RepositoryUrl, RequiredServiceState, RequiredServiceStatus, @@ -670,6 +672,57 @@ class DomainHealthCheck: timeout_seconds: PositiveCount +@dataclass(frozen=True, slots=True) +class DomainProxyRoute: + """A path a Domain proxies to an upstream, keeping the full request path.""" + + path: DomainRoutePath + upstream_address: IpAddress + upstream_port: TcpPort + + def as_dict(self) -> dict[str, object]: + """Serialize the route in its config shape.""" + return { + "path": self.path.value, + "upstream": { + "address": self.upstream_address.value, + "port": self.upstream_port.value, + }, + } + + +@dataclass(frozen=True, slots=True) +class DomainStaticRoute: + """A path a Domain serves as files from a component's current release.""" + + path: DomainRoutePath + component_id: ResourceId + directory: ReleaseRelativePath + spa_fallback: ReleaseRelativePath | None = None + + def __post_init__(self) -> None: + """Keep the alias-safe trailing slash and the release root out.""" + if not self.path.is_directory: + message = f"static route path must end with '/': {self.path.value!r}" + raise ValueError(message) + if self.directory.is_release_root: + message = "static route directory must not be the release root" + raise ValueError(message) + + def as_dict(self) -> dict[str, object]: + """Serialize the route in its config shape.""" + static: dict[str, object] = { + "component": self.component_id.value, + "directory": self.directory.value, + } + if self.spa_fallback is not None: + static["spaFallback"] = self.spa_fallback.value + return {"path": self.path.value, "static": static} + + +type DomainRoute = DomainProxyRoute | DomainStaticRoute + + @dataclass(frozen=True, slots=True) class DomainInventory: """Typed desired state for one public domain route.""" @@ -682,6 +735,13 @@ class DomainInventory: edge: DomainEdge tls_mode: TlsMode health_check: DomainHealthCheck + routes: tuple[DomainRoute, ...] = () + + def __post_init__(self) -> None: + """Hold at most one route source: ``proxy.upstream`` or ``routes``.""" + if self.routes and self.proxy.upstream_address is not None: + message = "a Domain declares proxy.upstream or routes, not both" + raise ValueError(message) def as_dict(self) -> dict[str, object]: """Serialize public routing intent without secret material.""" @@ -698,7 +758,7 @@ def as_dict(self) -> dict[str, object]: "address": self.proxy.upstream_address.value, "port": self.proxy.upstream_port.value, } - return { + result: dict[str, object] = { "id": self.resource_id.value, "primaryName": self.primary_name.value, "aliases": [alias.value for alias in self.aliases], @@ -725,6 +785,9 @@ def as_dict(self) -> dict[str, object]: "timeoutSeconds": self.health_check.timeout_seconds.value, }, } + if self.routes: + result["routes"] = [route.as_dict() for route in self.routes] + return result @dataclass(frozen=True, slots=True) @@ -1815,6 +1878,40 @@ def _domain_inventory(document: ResourceDocument) -> DomainInventory: ), timeout_seconds=PositiveCount.from_boundary(health.get("timeoutSeconds")), ), + routes=( + () + if spec.get("routes") is None + else tuple( + _domain_route(item) + for item in _mapping_list(spec.get("routes"), "domain routes") + ) + ), + ) + + +def _domain_route(route: Mapping[str, object]) -> DomainRoute: + path = DomainRoutePath.from_boundary(route.get("path")) + if route.get("static") is None: + upstream = _mapping(route, "upstream") + return DomainProxyRoute( + path=path, + upstream_address=IpAddress.from_boundary(upstream.get("address")), + upstream_port=TcpPort.from_boundary(upstream.get("port")), + ) + if route.get("upstream") is not None: + message = f"route {path.value!r} declares both upstream and static" + raise ValueError(message) + static = _mapping(route, "static") + raw_spa_fallback = static.get("spaFallback") + return DomainStaticRoute( + path=path, + component_id=ResourceId.from_boundary(static.get("component")), + directory=ReleaseRelativePath.from_boundary(static.get("directory")), + spa_fallback=( + None + if raw_spa_fallback is None + else ReleaseRelativePath.from_boundary(raw_spa_fallback) + ), ) diff --git a/sdk/src/cloudfall/validation.py b/sdk/src/cloudfall/validation.py index 8dfd5ed..7adf351 100644 --- a/sdk/src/cloudfall/validation.py +++ b/sdk/src/cloudfall/validation.py @@ -19,6 +19,7 @@ from referencing import Registry, Resource from cloudfall.domain import ( + DomainRoutePath, IpAddress, IpNetworkCidr, OpenSshPublicKey, @@ -571,6 +572,7 @@ def _validate_domain_references( document.source, ("spec", field_name, "server"), ) + _validate_domain_routes(document, spec, proxy, index) primary_name = spec.get("primaryName") aliases = spec.get("aliases") if isinstance(aliases, list) and primary_name in aliases: @@ -593,6 +595,144 @@ def _validate_domain_references( raise ConfigValidationError(issue) +def _validate_domain_routes( + document: ResourceDocument, + spec: Mapping[str, object], + proxy: Mapping[str, object], + index: Mapping[ResourceKey, ResourceDocument], +) -> None: + """Check ``spec.routes``: one route source, safe unique paths, served components. + + The schema holds each route's shape, ``directory`` and ``spaFallback``; the + path rules live here so each refusal names its own code. + """ + if "routes" not in spec: + return + if "upstream" in proxy: + issue = ValidationIssue( + code="domain_route_source_invalid", + message="a Domain declares proxy.upstream or routes, not both", + source=document.source, + field_path=("spec", "routes"), + ) + raise ConfigValidationError(issue) + proxy_server_id = _resource_id_value(proxy, "server", document.source) + raw_routes = spec.get("routes") + if not isinstance(raw_routes, list): + issue = ValidationIssue( + code="internal_state_shape_invalid", + message="validated field 'routes' is not a list", + source=document.source, + field_path=("spec", "routes"), + ) + raise ConfigValidationError(issue) + seen: set[DomainRoutePath] = set() + for position, raw_route in enumerate(raw_routes): + route_field: tuple[str | int, ...] = ("spec", "routes", position) + if not isinstance(raw_route, dict): + issue = ValidationIssue( + code="internal_state_shape_invalid", + message="validated route is not an object", + source=document.source, + field_path=route_field, + ) + raise ConfigValidationError(issue) + route = cast("Mapping[str, object]", raw_route) + path = _domain_route_path(route, document.source, (*route_field, "path")) + if path in seen: + issue = ValidationIssue( + code="domain_route_path_duplicate", + message=f"two routes declare the path {path.value!r}", + source=document.source, + field_path=(*route_field, "path"), + ) + raise ConfigValidationError(issue) + seen.add(path) + if "static" in route: + _validate_static_route( + document, + path, + _required_mapping(route, "static", document.source), + proxy_server_id, + index, + route_field, + ) + + +def _domain_route_path( + route: Mapping[str, object], + source: SourceLocation, + field_path: tuple[str | int, ...], +) -> DomainRoutePath: + try: + path = DomainRoutePath.from_boundary(route.get("path")) + except (TypeError, ValueError) as error: + issue = ValidationIssue( + code="domain_route_path_invalid", + message=str(error), + source=source, + field_path=field_path, + ) + raise ConfigValidationError(issue) from error + if path.is_reserved: + issue = ValidationIssue( + code="domain_route_path_reserved", + message=( + f"route path {path.value!r} is under /.well-known/acme-challenge, " + "which certificate issuance owns" + ), + source=source, + field_path=field_path, + ) + raise ConfigValidationError(issue) + return path + + +def _validate_static_route( # noqa: PLR0913 + document: ResourceDocument, + path: DomainRoutePath, + static: Mapping[str, object], + proxy_server_id: ResourceId, + index: Mapping[ResourceKey, ResourceDocument], + route_field: tuple[str | int, ...], +) -> None: + if not path.is_directory: + issue = ValidationIssue( + code="domain_route_path_invalid", + message=( + f"static route path {path.value!r} must end with '/', so nginx " + "can't serve files from outside its directory" + ), + source=document.source, + field_path=(*route_field, "path"), + ) + raise ConfigValidationError(issue) + component_field = (*route_field, "static", "component") + component_id = _resource_id_value(static, "component", document.source) + component = _require_resource( + index, + ResourceKind.COMPONENT, + component_id, + document.source, + component_field, + ) + component_spec = _required_mapping(component.content, "spec", component.source) + deployment = _required_mapping(component_spec, "deployment", component.source) + if proxy_server_id not in _resource_id_list( + deployment, "servers", component.source + ): + issue = ValidationIssue( + code="domain_route_component_not_on_proxy", + message=( + f"Component/{component_id} is not deployed on the proxy server " + f"{proxy_server_id}, where nginx reads its files" + ), + source=document.source, + field_path=component_field, + ) + raise ConfigValidationError(issue) + + def _validate_service_references( document: ResourceDocument, spec: Mapping[str, object], diff --git a/sdk/tests/test_domain_routes.py b/sdk/tests/test_domain_routes.py new file mode 100644 index 0000000..0df3301 --- /dev/null +++ b/sdk/tests/test_domain_routes.py @@ -0,0 +1,308 @@ +"""Domain routes: the `routes` schema, its value objects and its validation (S-003).""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest +from cloudfall.cli import main +from cloudfall.domain import DomainRoutePath, ReleaseRelativePath, ResourceId +from cloudfall.inventory import ( + DomainProxyRoute, + DomainStaticRoute, + PlatformInventory, +) +from cloudfall.validation import validate_config + +ROOT = Path(__file__).parents[2] +SCHEMAS = ROOT / "config" / "schemas" / "v1" +EXAMPLES = ROOT / "config" / "examples" + +_UPSTREAM = " upstream:\n address: 127.0.0.1\n port: 8100\n" +_SPA_ROUTES = """\ + routes: + - path: /api/ + upstream: { address: 127.0.0.1, port: 8310 } + - path: / + static: + component: crm-backend + directory: web/dist + spaFallback: index.html +""" + + +def _project(tmp_path: Path, routes: str, *, keep_upstream: bool = False) -> Path: + """Copy the examples and give their Domain ``routes`` in place of its upstream.""" + project = tmp_path / "config" + shutil.copytree(EXAMPLES, project) + path = project / "domains" / "crm-site.yaml" + text = path.read_text(encoding="utf-8") + assert _UPSTREAM in text + if not keep_upstream: + text = text.replace(_UPSTREAM, "") + text = text.replace(" origin:\n", routes + " origin:\n") + path.write_text(text, encoding="utf-8") + return project + + +def _validate( + project: Path, capsys: pytest.CaptureFixture[str] +) -> tuple[int, dict[str, Any]]: + exit_code = main( + [ + "config", + "validate", + "--project", + str(project), + "--schemas", + str(SCHEMAS), + ] + ) + return exit_code, json.loads(capsys.readouterr().out) + + +def _refused( + project: Path, capsys: pytest.CaptureFixture[str] +) -> dict[str, object]: + exit_code, payload = _validate(project, capsys) + assert exit_code == 80 + assert payload["error"]["code"] == "CONFIG_INVALID" + context: dict[str, object] = payload["error"]["context"] + return context + + +def _static_route(path: str, directory: str = "web/dist", extra: str = "") -> str: + return ( + " routes:\n" + f" - path: {path}\n" + " static:\n" + " component: crm-backend\n" + f" directory: {directory}\n" + f"{extra}" + ) + + +def _proxy_route(path: str) -> str: + return ( + " routes:\n" + f" - path: {path}\n" + " upstream: { address: 127.0.0.1, port: 8310 }\n" + ) + + +def test_s003_1_proxy_and_spa_static_routes_pass_config_validate( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = _project(tmp_path, _SPA_ROUTES) + + exit_code, payload = _validate(project, capsys) + + assert exit_code == 0 + assert payload["data"]["status"] == "ok" + domain =PlatformInventory.from_state(validate_config(project, SCHEMAS)).domains[0] + assert domain.proxy.upstream_address is None + api, web = domain.routes + assert isinstance(api, DomainProxyRoute) + assert api.path == DomainRoutePath("/api/") + assert (api.upstream_address.value, api.upstream_port.value) == ("127.0.0.1", 8310) + assert isinstance(web, DomainStaticRoute) + assert web.component_id == ResourceId("crm-backend") + assert web.directory == ReleaseRelativePath("web/dist") + assert web.spa_fallback == ReleaseRelativePath("index.html") + serialized: dict[str, Any] = domain.as_dict() + assert "upstream" not in serialized["proxy"] + assert serialized["routes"] == [ + {"path": "/api/", "upstream": {"address": "127.0.0.1", "port": 8310}}, + { + "path": "/", + "static": { + "component": "crm-backend", + "directory": "web/dist", + "spaFallback": "index.html", + }, + }, + ] + + +def test_s003_1_a_domain_with_only_an_upstream_serializes_as_before() -> None: + domain = PlatformInventory.from_state(validate_config(EXAMPLES, SCHEMAS)).domains[0] + + assert domain.routes == () + assert "routes" not in domain.as_dict() + + +def test_s003_8_two_routes_for_one_path_are_refused( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + routes = ( + " routes:\n" + " - path: /api/\n" + " upstream: { address: 127.0.0.1, port: 8310 }\n" + " - path: /api/\n" + " upstream: { address: 127.0.0.1, port: 8311 }\n" + ) + + context = _refused(_project(tmp_path, routes), capsys) + + assert context["code"] == "domain_route_path_duplicate" + assert context["field"] == "spec.routes.1.path" + + +def test_s003_9_a_domain_with_upstream_and_routes_is_refused( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + context = _refused(_project(tmp_path, _SPA_ROUTES, keep_upstream=True), capsys) + + assert context["code"] == "domain_route_source_invalid" + assert context["field"] == "spec.routes" + + +def test_s003_9_a_domain_with_neither_upstream_nor_routes_still_passes( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + exit_code, payload = _validate(_project(tmp_path, ""), capsys) + + assert exit_code == 0 + assert payload["data"]["status"] == "ok" + + +@pytest.mark.parametrize( + "path", + [ + "/.well-known/acme-challenge", + "/.well-known/acme-challenge/", + "/.well-known/acme-challenge/token/", + ], +) +def test_s003_10_a_route_on_the_acme_challenge_is_refused( + tmp_path: Path, capsys: pytest.CaptureFixture[str], path: str +) -> None: + context = _refused(_project(tmp_path, _proxy_route(path)), capsys) + + assert context["code"] == "domain_route_path_reserved" + assert context["field"] == "spec.routes.0.path" + + +@pytest.mark.parametrize( + "routes", + [ + _static_route("/web"), + _static_route("'/a b/'"), + _static_route("'/a;b/'"), + _static_route("'/a{b/'"), + _proxy_route("api/"), + _proxy_route("'/a b'"), + _proxy_route("''"), + ], + ids=[ + "static-no-slash", + "space", + "semicolon", + "brace", + "relative", + "proxy-space", + "empty", + ], +) +def test_s003_10_an_invalid_route_path_is_refused( + tmp_path: Path, capsys: pytest.CaptureFixture[str], routes: str +) -> None: + context = _refused(_project(tmp_path, routes), capsys) + + assert context["code"] == "domain_route_path_invalid" + assert context["field"] == "spec.routes.0.path" + + +def test_s003_10_a_proxy_route_path_needs_no_trailing_slash( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + routes = _proxy_route("/api.v1_~x-y") + + exit_code, _ = _validate(_project(tmp_path, routes), capsys) + + assert exit_code == 0 + + +def test_s003_11_a_static_route_naming_a_missing_component_is_refused( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + routes = _static_route("/").replace("crm-backend", "crm-missing") + + context = _refused(_project(tmp_path, routes), capsys) + + assert context["code"] == "resource_reference_missing" + assert context["field"] == "spec.routes.0.static.component" + assert "Component/crm-missing" in str(context["message"]) + + +def test_s003_11_a_static_component_off_the_proxy_server_is_refused( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = _project(tmp_path, _static_route("/")) + component = project / "components" / "crm-backend.yaml" + text = component.read_text(encoding="utf-8") + component.write_text( + text.replace(" - h1\n - h2\n", " - h1\n"), encoding="utf-8" + ) + + context = _refused(project, capsys) + + assert context["code"] == "domain_route_component_not_on_proxy" + assert context["field"] == "spec.routes.0.static.component" + assert "h2" in str(context["message"]) + + +@pytest.mark.parametrize( + "routes", + [ + _static_route("/", directory="../web"), + _static_route("/", directory="web/../../etc"), + _static_route("/", directory="/srv/web"), + _static_route("/", directory="."), + _static_route("/", directory="./"), + _static_route("/", directory="'web dist'"), + _static_route("/", extra=" spaFallback: ../index.html\n"), + _static_route("/", extra=" spaFallback: /index.html\n"), + _static_route("/", extra=" spaFallback: a/../../index.html\n"), + ], + ids=[ + "directory-parent", + "directory-inner-parent", + "directory-absolute", + "directory-root", + "directory-root-slash", + "directory-space", + "fallback-parent", + "fallback-absolute", + "fallback-inner-parent", + ], +) +def test_s003_12_a_static_directory_or_fallback_outside_the_release_is_refused( + tmp_path: Path, capsys: pytest.CaptureFixture[str], routes: str +) -> None: + context = _refused(_project(tmp_path, routes), capsys) + + assert context["code"] == "schema_validation_failed" + assert str(context["field"]).startswith("spec.routes.0") + + +def test_s003_12_route_value_objects_match_the_schema() -> None: + for path in ("/", "/api/", "/a.b_c~d-e/f"): + assert DomainRoutePath.from_boundary(path).value == path + for path in ("", "api/", "/a b", "/a;b", "/a{b}", "/a\nb"): + with pytest.raises(ValueError, match="invalid route path"): + DomainRoutePath(path) + assert DomainRoutePath("/.well-known/acme-challenge/x").is_reserved + assert not DomainRoutePath("/.well-known/").is_reserved + assert not DomainRoutePath("/.well-known/acme-challenger").is_reserved + for value in ("web/dist", "index.html", "...", "a/./b"): + assert ReleaseRelativePath.from_boundary(value).value == value + for value in ("", "/web", "..", "a/../b", "a b", "a;b"): + with pytest.raises(ValueError, match="invalid release path"): + ReleaseRelativePath(value) + for root in (".", "./", "././", ".//."): + assert ReleaseRelativePath(root).is_release_root + assert not ReleaseRelativePath("web").is_release_root From 6eb5d87924729749a676da02129121c49a8b8b31 Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:11:15 +0200 Subject: [PATCH 2/2] Refuse a trailing newline in a static route's directory or spaFallback The releasePath schema pattern ends with $, which Python's re.search lets match before one trailing newline, so config validate accepted directory: "web\n" that ReleaseRelativePath then refused with a ValueError when the inventory loaded. Refuse control characters in the schema, as component.schema.json's controlCharacterFree does. Co-Authored-By: Claude Opus 5.5 --- config/schemas/v1/domain.schema.json | 5 ++++- sdk/tests/test_domain_routes.py | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/config/schemas/v1/domain.schema.json b/config/schemas/v1/domain.schema.json index fb9f211..c3f6028 100644 --- a/config/schemas/v1/domain.schema.json +++ b/config/schemas/v1/domain.schema.json @@ -32,7 +32,10 @@ }, "releasePath": { "type": "string", - "pattern": "^(?!/)(?!(?:.*/)?\\.\\.(?:/|$))[A-Za-z0-9._/-]+$" + "pattern": "^(?!/)(?!(?:.*/)?\\.\\.(?:/|$))[A-Za-z0-9._/-]+$", + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } }, "route": { "type": "object", diff --git a/sdk/tests/test_domain_routes.py b/sdk/tests/test_domain_routes.py index 0df3301..dbae945 100644 --- a/sdk/tests/test_domain_routes.py +++ b/sdk/tests/test_domain_routes.py @@ -264,9 +264,11 @@ def test_s003_11_a_static_component_off_the_proxy_server_is_refused( _static_route("/", directory="."), _static_route("/", directory="./"), _static_route("/", directory="'web dist'"), + _static_route("/", directory='"web\\n"'), _static_route("/", extra=" spaFallback: ../index.html\n"), _static_route("/", extra=" spaFallback: /index.html\n"), _static_route("/", extra=" spaFallback: a/../../index.html\n"), + _static_route("/", extra=' spaFallback: "index.html\\n"\n'), ], ids=[ "directory-parent", @@ -275,9 +277,11 @@ def test_s003_11_a_static_component_off_the_proxy_server_is_refused( "directory-root", "directory-root-slash", "directory-space", + "directory-trailing-newline", "fallback-parent", "fallback-absolute", "fallback-inner-parent", + "fallback-trailing-newline", ], ) def test_s003_12_a_static_directory_or_fallback_outside_the_release_is_refused(