diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index f86b61402..118816bd7 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -2,8 +2,8 @@ # # Any product repo in the org can call this to publish a static site to # Cloudflare Pages and (optionally) attach a custom domain, using the org -# secrets CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID. The token stays inside -# GitHub Actions — callers pass `secrets: inherit`. +# secrets CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID. This workflow references +# only these two declared values. Approved CWL callers map them explicitly. # # Example caller (.github/workflows/site.yml in a product repo): # @@ -18,7 +18,9 @@ # project_name: keyverse-marketing # Cloudflare Pages project (snake/kebab ok) # build_dir: ./public # directory of built static assets # custom_domain: keyverse.io # optional; must have a CF zone first -# secrets: inherit +# secrets: +# CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} +# CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # name: Deploy Cloudflare Pages @@ -38,13 +40,20 @@ on: required: false type: string default: "" + secrets: + CLOUDFLARE_API_TOKEN: + description: "Cloudflare API token scoped to Pages deployment" + required: true + CLOUDFLARE_ACCOUNT_ID: + description: "Cloudflare account identifier that owns the Pages project" + required: true permissions: contents: read jobs: deploy_pages: - name: Deploy ${{ inputs.project_name }} + name: Deploy Cloudflare Pages runs-on: ubuntu-latest steps: - name: Checkout caller repo @@ -57,25 +66,115 @@ jobs: run: | set -euo pipefail if [ -z "${CF_API_TOKEN}" ] || [ -z "${CF_ACCOUNT_ID}" ]; then - echo "::error::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID not available. Caller must use 'secrets: inherit'." + echo "::error::CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID not available. Caller must map both declared reusable-workflow secrets." exit 1 fi + - name: Validate deployment inputs + id: deploy_inputs + env: + RAW_PROJECT_NAME: ${{ inputs.project_name }} + RAW_BUILD_DIR: ${{ inputs.build_dir }} + RAW_CUSTOM_DOMAIN: ${{ inputs.custom_domain }} + run: | + set -euo pipefail + python3 - <<'PYTHON' + from __future__ import annotations + + import os + import re + import sys + from pathlib import Path + + SAFE_PROJECT = re.compile( + r"[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\Z" + ) + SAFE_PATH = re.compile(r"[A-Za-z0-9._/-]+\Z") + SAFE_LABEL = re.compile( + r"[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\Z" + ) + + + def invalid() -> None: + """Fail closed without reflecting an untrusted input value.""" + print( + "::error::Invalid Cloudflare Pages deployment input.", + file=sys.stderr, + ) + raise SystemExit(1) + + + project_name = os.environ.get("RAW_PROJECT_NAME", "") + if ( + not 1 <= len(project_name) <= 100 + or SAFE_PROJECT.fullmatch(project_name) is None + ): + invalid() + + raw_build_dir = os.environ.get("RAW_BUILD_DIR", "") + if raw_build_dir.startswith("./"): + build_dir = raw_build_dir[2:] + else: + build_dir = raw_build_dir + if ( + not 1 <= len(build_dir) <= 512 + or build_dir.startswith("-") + or SAFE_PATH.fullmatch(build_dir) is None + ): + invalid() + path_parts = build_dir.split("/") + if any(part in {"", ".", ".."} for part in path_parts): + invalid() + + workspace_raw = os.environ.get("GITHUB_WORKSPACE", "") + if not workspace_raw: + invalid() + try: + workspace = Path(workspace_raw).resolve(strict=True) + build_path = (workspace / build_dir).resolve(strict=True) + build_path.relative_to(workspace) + except (OSError, RuntimeError, ValueError): + invalid() + if not build_path.is_dir(): + invalid() + + custom_domain = os.environ.get("RAW_CUSTOM_DOMAIN", "") + if custom_domain: + if not 1 <= len(custom_domain) <= 253: + invalid() + labels = custom_domain.split(".") + if len(labels) < 2 or any( + not 1 <= len(label) <= 63 + or SAFE_LABEL.fullmatch(label) is None + for label in labels + ): + invalid() + custom_domain = custom_domain.lower() + + output_path = os.environ.get("GITHUB_OUTPUT", "") + if not output_path: + invalid() + with Path(output_path).open("a", encoding="utf-8") as handle: + handle.write(f"project_name={project_name}\n") + handle.write(f"build_dir={build_dir}\n") + handle.write(f"custom_domain={custom_domain}\n") + PYTHON + - name: Deploy to Cloudflare Pages (wrangler) uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - # Creates the project on first run; publishes the build_dir to production. - command: pages deploy ${{ inputs.build_dir }} --project-name=${{ inputs.project_name }} + # Creates the project on first run; publishes the validated build_dir to production. + command: pages deploy ${{ steps.deploy_inputs.outputs.build_dir }} --project-name=${{ steps.deploy_inputs.outputs.project_name }} - name: Attach custom domain (idempotent) - if: ${{ inputs.custom_domain != '' }} + if: ${{ steps.deploy_inputs.outputs.custom_domain != '' }} env: CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - PROJECT_NAME: ${{ inputs.project_name }} - CUSTOM_DOMAIN: ${{ inputs.custom_domain }} + PROJECT_NAME: ${{ steps.deploy_inputs.outputs.project_name }} + CUSTOM_DOMAIN: ${{ steps.deploy_inputs.outputs.custom_domain }} run: | set -euo pipefail api="https://api.cloudflare.com/client/v4" @@ -102,11 +201,19 @@ jobs: - name: Summary if: always() + env: + PROJECT_NAME: ${{ steps.deploy_inputs.outputs.project_name }} + BUILD_DIR: ${{ steps.deploy_inputs.outputs.build_dir }} + CUSTOM_DOMAIN: ${{ steps.deploy_inputs.outputs.custom_domain }} run: | + set -euo pipefail + custom_domain="${CUSTOM_DOMAIN}" + if [ -z "${custom_domain}" ]; then + custom_domain="(none)" + fi { - echo "## Cloudflare Pages deploy" - echo "" - echo "- **Project:** \`${{ inputs.project_name }}\`" - echo "- **Build dir:** \`${{ inputs.build_dir }}\`" - echo "- **Custom domain:** \`${{ inputs.custom_domain || '(none)' }}\`" + printf '## Cloudflare Pages deploy\n\n' + printf -- '- **Project:** `%s`\n' "${PROJECT_NAME}" + printf -- '- **Build dir:** `%s`\n' "${BUILD_DIR}" + printf -- '- **Custom domain:** `%s`\n' "${custom_domain}" } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index bf30091dd..ecfe0c480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Replaced blanket inherited-secret guidance at the reusable Cloudflare Pages + deployment boundary with an explicit two-secret, required caller contract. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. diff --git a/docs/doctoring/deploy-pages-secret-contract.md b/docs/doctoring/deploy-pages-secret-contract.md new file mode 100644 index 000000000..fda59fa19 --- /dev/null +++ b/docs/doctoring/deploy-pages-secret-contract.md @@ -0,0 +1,112 @@ +# Cloudflare Pages reusable-workflow secret and input contract + +## Decision + +The reusable Pages deployment declares and references exactly two required +secret names for compliant callers: `CLOUDFLARE_API_TOKEN` and +`CLOUDFLARE_ACCOUNT_ID`. Approved CWL callers MUST map them explicitly and +MUST NOT use `secrets: inherit`: + +```yaml +jobs: + deploy: + uses: ContextualWisdomLab/.github/.github/workflows/deploy-pages.yml@main + with: + project_name: example-marketing + build_dir: ./public + secrets: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} +``` + +This is an explicit named interface and caller policy, not a GitHub runtime +allowlist. GitHub allows a same-organization or same-enterprise caller to use +`secrets: inherit`; secrets inherited that way can be referenced by the called +workflow even when they are not declared under `on.workflow_call.secrets`. +The declaration documents and validates explicit named mappings but cannot +disable GitHub's inheritance keyword. The called workflow itself references +only the two Cloudflare names above. + +The workflow keeps `contents: read`, checks out the caller repository, and uses +the token only for Pages deployment and optional domain attachment. For an +approved explicit-mapping caller, GitHub rejects an invocation that omits either +required name before the job starts; the runtime guard remains a value-free +defense-in-depth check. + +## Untrusted deployment inputs + +Reusable-workflow string inputs are caller-controlled data. They are not +command, filesystem, Cloudflare-resource, or URL authority merely because the +caller is allowed to invoke the workflow. The workflow therefore validates the +three deployment inputs before any of them reaches Wrangler, a Cloudflare API +URL, or a shell-rendered summary. + +`project_name` is bounded to a non-empty alphanumeric/hyphen identifier with an +alphanumeric first and last character. `build_dir` is bounded to a relative +POSIX-style path, may not contain option-like, traversal, whitespace, control, +or backslash syntax, must resolve to an existing directory, and must remain +inside the exact checked-out `GITHUB_WORKSPACE` after symlink resolution. +`custom_domain` is optional; when present it is bounded to DNS-style labels, +rejects path/query/fragment/port-like syntax, and is canonicalized to lowercase. +Invalid input fails with one generic value-free error rather than reflecting the +untrusted value. + +Only the validator's sealed step outputs reach the Wrangler command, custom +domain API path, and job summary. The job display name is static so a raw +caller-supplied project name is not promoted into workflow presentation before +validation. The summary passes validated outputs through environment variables +rather than embedding raw GitHub expression text into the shell program. + +Cloudflare's current Direct Upload documentation defines Pages deployment as +uploading one prebuilt asset directory with `wrangler pages deploy`, and its CI +guide uses the directory plus `--project-name=`. This workflow +keeps exactly that product boundary while adding a stricter central validation +layer before argument construction. The validator is intentionally more +restrictive than accepting arbitrary strings: callers requiring a genuinely new +identifier/path shape must change the reviewed contract and its negative tests, +not bypass validation locally. + +## Migration and acceptance + +As of 2026-08-09, a current organization search found no product workflow that +calls this reusable workflow. Re-run that search before merge. Any consumer +found later must add the two explicit mappings in its thin caller under that +repository's writer lease. Treat any `secrets: inherit` caller as a leaf +migration defect, not a reason to broaden the central interface. + +Acceptance requires workflow contract tests, syntax and supply-chain checks, +and realistic positive/negative input tests that execute the production +validator itself. At minimum the tests cover a normal project/build/domain, +argument-like project names, absolute/traversing/build-option paths, malformed +hostnames, and a symlink escaping the checkout. A protected-main caller canary +must prove that validated inputs reach Wrangler with both required secret +mappings. A missing-mapping negative control must stop before deployment and +must not print a credential. + +## Failure and rollback + +If a consumer cannot migrate immediately, pin it to the last reviewed workflow +revision while its caller is repaired. Do not broaden the new interface, +reintroduce blanket inheritance, or interpolate raw inputs as a compatibility +shortcut. Roll back the central contract only for a confirmed GitHub reusable- +workflow or Cloudflare platform defect, and preserve the explicit two-name +secret interface plus fail-closed input validation in the replacement +transport. + +If validation rejects a previously accepted caller, first determine whether the +caller relied on a genuinely supported Cloudflare identifier/path shape or on +ambiguous input that should never have crossed the command/URL boundary. Extend +the validator only with a focused RED/GREEN contract and keep symlink escape, +traversal, option injection, and value-free diagnostics intact. + +## APA 7th references + +Cloudflare. (2026a). *Direct Upload*. Cloudflare Pages documentation. +https://developers.cloudflare.com/pages/get-started/direct-upload/ + +Cloudflare. (2026b). *Use Direct Upload with continuous integration*. Cloudflare +Pages documentation. +https://developers.cloudflare.com/pages/how-to/use-direct-upload-with-continuous-integration/ + +GitHub. (2026). *Reuse workflows*. GitHub Docs. +https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows diff --git a/infra/cloudflare/README.md b/infra/cloudflare/README.md index 3a7898d61..df19be3b7 100644 --- a/infra/cloudflare/README.md +++ b/infra/cloudflare/README.md @@ -76,7 +76,8 @@ deleted unless you explicitly set `prune = true`. ## Deploying a product's static site to Cloudflare Pages -Product repos call the reusable workflow and inherit the org secrets: +Product repos call the reusable workflow and explicitly map the two declared +Cloudflare secret names: ```yaml # .github/workflows/site.yml in e.g. cwl-idp (Keyverse) @@ -91,9 +92,15 @@ jobs: project_name: keyverse-marketing build_dir: ./public custom_domain: keyverse.io # optional; the CF zone must already exist - secrets: inherit + secrets: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} ``` +Approved CWL callers MUST keep these explicit mappings and MUST NOT use +`secrets: inherit`; see +[the reusable-workflow secret contract](../../docs/doctoring/deploy-pages-secret-contract.md). + The reusable workflow publishes `build_dir` to the named Pages project (creating it on first run) via `wrangler pages deploy`, then idempotently attaches `custom_domain` if provided. After attaching a custom domain, add the matching diff --git a/tests/test_deploy_pages_secret_contract.py b/tests/test_deploy_pages_secret_contract.py new file mode 100644 index 000000000..704bbe230 --- /dev/null +++ b/tests/test_deploy_pages_secret_contract.py @@ -0,0 +1,273 @@ +"""Least-privilege and input-safety contracts for Pages deployment.""" + +from __future__ import annotations + +import os +import re +import subprocess +import textwrap +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_PATH = REPO_ROOT / ".github/workflows/deploy-pages.yml" +POLICY_PATH = REPO_ROOT / "docs/doctoring/deploy-pages-secret-contract.md" +INFRA_GUIDE_PATH = REPO_ROOT / "infra/cloudflare/README.md" +EXPECTED_SECRETS = {"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"} +SECRET_DECLARATION_RE = re.compile( + r"^ ([A-Z][A-Z0-9_]+):[ \t]*(?:#.*)?$", re.MULTILINE +) +INHERITED_SECRETS_RE = re.compile( + r"""(?m)^[ \t]*secrets[ \t]*:[ \t]*(?:inherit|["']inherit["'])[ \t]*(?:#.*)?$""" +) + + +def workflow_text() -> str: + """Return the authoritative reusable Pages workflow text.""" + return WORKFLOW_PATH.read_text(encoding="utf-8") + + +def deployment_input_validation_script() -> str: + """Return the executable deployment-input validator from the workflow.""" + workflow = workflow_text() + marker = " - name: Validate deployment inputs\n" + assert marker in workflow + step = workflow.split(marker, 1)[1].split("\n - name:", 1)[0] + run_marker = " run: |\n" + assert run_marker in step + return textwrap.dedent(step.split(run_marker, 1)[1]) + + +def run_deployment_input_validation( + tmp_path: Path, + *, + project_name: str = "safe-project", + build_dir: str = "./public", + custom_domain: str = "www.example.com", +) -> subprocess.CompletedProcess[str]: + """Execute the production validator against one isolated caller workspace.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "public").mkdir() + output = tmp_path / "github-output.txt" + environment = os.environ.copy() + environment.update( + { + "GITHUB_WORKSPACE": str(workspace), + "GITHUB_OUTPUT": str(output), + "RAW_PROJECT_NAME": project_name, + "RAW_BUILD_DIR": build_dir, + "RAW_CUSTOM_DOMAIN": custom_domain, + } + ) + return subprocess.run( + ["bash", "-euo", "pipefail", "-c", deployment_input_validation_script()], + cwd=workspace, + env=environment, + text=True, + capture_output=True, + check=False, + ) + + +def test_deploy_pages_declares_only_required_cloudflare_secrets() -> None: + """Declare and consume only the two Cloudflare names in this workflow.""" + workflow = workflow_text() + call_contract = workflow.split(" workflow_call:\n", 1)[1].split( + "\npermissions:\n", 1 + )[0] + + assert " secrets:\n" in call_contract + for secret_name in EXPECTED_SECRETS: + secret_contract = re.search( + rf"^ {re.escape(secret_name)}:\n(?P(?: .*\n?)+)", + call_contract, + re.MULTILINE, + ) + assert secret_contract is not None + assert "required: true" in secret_contract.group("body") + declared = set(SECRET_DECLARATION_RE.findall(call_contract)) + assert declared == EXPECTED_SECRETS + referenced = set(re.findall(r"secrets\.([A-Z][A-Z0-9_]+)", workflow)) + assert referenced == EXPECTED_SECRETS + + +@pytest.mark.parametrize( + "declaration", + ( + " EXTRA_SECRET:\n", + " EXTRA_SECRET: \n", + " EXTRA_SECRET: # comment\n", + " EXTRA_SECRET:\t# comment\n", + ), +) +def test_declared_secret_parser_covers_valid_yaml_comment_variants( + declaration: str, +) -> None: + """Detect an added reusable secret even when YAML adds spacing or comments.""" + assert set(SECRET_DECLARATION_RE.findall(declaration)) == {"EXTRA_SECRET"} + + +def test_deploy_pages_examples_map_secrets_explicitly() -> None: + """Prevent executable central examples from restoring blanket inheritance.""" + workflow = workflow_text() + documents = ( + POLICY_PATH.read_text(encoding="utf-8"), + INFRA_GUIDE_PATH.read_text(encoding="utf-8"), + ) + examples = [workflow] + + for document in documents: + fenced_yaml = [ + block.split("\n```", 1)[0] + for block in document.split("```yaml\n")[1:] + if "\n```" in block + ] + assert fenced_yaml + examples.extend(fenced_yaml) + + for example in examples: + assert INHERITED_SECRETS_RE.search(example) is None + assert "CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}" in example + assert "CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" in example + + +@pytest.mark.parametrize( + "mapping", + ( + "secrets: inherit", + "secrets: inherit", + " secrets: inherit # caller shortcut", + "secrets: 'inherit'", + 'secrets: "inherit" # caller shortcut', + ), +) +def test_inherit_detector_covers_yaml_spacing_quote_and_comment_variants( + mapping: str, +) -> None: + """Recognize every supported scalar spelling of forbidden blanket inheritance.""" + assert INHERITED_SECRETS_RE.search(mapping) is not None + + +def test_deploy_pages_policy_records_inherit_platform_boundary() -> None: + """Document GitHub inheritance limits without weakening CWL caller policy.""" + policy = " ".join(POLICY_PATH.read_text(encoding="utf-8").split()) + + for required_boundary in ( + "Approved CWL callers MUST map them explicitly and MUST NOT use `secrets: inherit`", + "not a GitHub runtime allowlist", + "can be referenced by the called workflow even when they are not declared under `on.workflow_call.secrets`", + "cannot disable GitHub's inheritance keyword", + "leaf migration defect", + ): + assert required_boundary in policy + + +def test_deploy_pages_missing_secret_diagnostic_does_not_print_values() -> None: + """Keep the defense-in-depth guard fail-closed and value-free.""" + workflow = workflow_text() + guard = workflow.split(" - name: Guard secrets present\n", 1)[1].split( + "\n - name:", 1 + )[0] + + assert 'if [ -z "${CF_API_TOKEN}" ] || [ -z "${CF_ACCOUNT_ID}" ]; then' in guard + assert "Caller must map both declared reusable-workflow secrets." in guard + assert 'echo "${CF_API_TOKEN}"' not in guard + assert 'echo "${CF_ACCOUNT_ID}"' not in guard + + +def test_deploy_pages_validates_inputs_before_command_or_url_use() -> None: + """Only validated outputs may reach Wrangler, Cloudflare URLs, or summaries.""" + workflow = workflow_text() + validate_at = workflow.index(" - name: Validate deployment inputs\n") + deploy_at = workflow.index(" - name: Deploy to Cloudflare Pages (wrangler)\n") + attach_at = workflow.index(" - name: Attach custom domain (idempotent)\n") + summary_at = workflow.index(" - name: Summary\n") + assert validate_at < deploy_at < attach_at < summary_at + + validator = workflow[validate_at:deploy_at] + for raw_name in ("project_name", "build_dir", "custom_domain"): + assert f"RAW_{raw_name.upper()}: ${{{{ inputs.{raw_name} }}}}" in validator + + deployment = workflow[deploy_at:attach_at] + assert "${{ inputs.project_name }}" not in deployment + assert "${{ inputs.build_dir }}" not in deployment + assert "steps.deploy_inputs.outputs.project_name" in deployment + assert "steps.deploy_inputs.outputs.build_dir" in deployment + + post_validation = workflow[attach_at:] + for raw_name in ("project_name", "build_dir", "custom_domain"): + assert f"${{{{ inputs.{raw_name} }}}}" not in post_validation + assert "steps.deploy_inputs.outputs.project_name" in post_validation + assert "steps.deploy_inputs.outputs.custom_domain" in post_validation + + +def test_deploy_pages_validator_accepts_bounded_canonical_inputs(tmp_path: Path) -> None: + """Accept a normal project, repository-local build directory, and DNS name.""" + completed = run_deployment_input_validation(tmp_path) + assert completed.returncode == 0, completed.stderr + output = (tmp_path / "github-output.txt").read_text(encoding="utf-8") + assert "project_name=safe-project\n" in output + assert "build_dir=public\n" in output + assert "custom_domain=www.example.com\n" in output + + +@pytest.mark.parametrize( + ("field", "value"), + ( + ("project_name", "safe project"), + ("project_name", "safe;project"), + ("project_name", "safe/project"), + ("project_name", "--help"), + ("project_name", "safe.project"), + ("build_dir", "../public"), + ("build_dir", "/tmp/public"), + ("build_dir", "public --branch=evil"), + ("build_dir", "public;echo-pwn"), + ("custom_domain", "example.com/path"), + ("custom_domain", "example.com?x=1"), + ("custom_domain", "bad domain.example"), + ("custom_domain", "-bad.example"), + ("custom_domain", "bad-.example"), + ), +) +def test_deploy_pages_validator_rejects_argument_and_path_injection( + tmp_path: Path, field: str, value: str +) -> None: + """Reject command, option, traversal, URL-path, and malformed-host inputs.""" + kwargs = {field: value} + completed = run_deployment_input_validation(tmp_path, **kwargs) + assert completed.returncode != 0 + assert "::error::Invalid Cloudflare Pages deployment input." in completed.stderr + + +def test_deploy_pages_validator_rejects_symlink_escape(tmp_path: Path) -> None: + """A caller build-directory symlink cannot escape the checked-out workspace.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (workspace / "public").symlink_to(outside, target_is_directory=True) + output = tmp_path / "github-output.txt" + environment = os.environ.copy() + environment.update( + { + "GITHUB_WORKSPACE": str(workspace), + "GITHUB_OUTPUT": str(output), + "RAW_PROJECT_NAME": "safe-project", + "RAW_BUILD_DIR": "./public", + "RAW_CUSTOM_DOMAIN": "", + } + ) + completed = subprocess.run( + ["bash", "-euo", "pipefail", "-c", deployment_input_validation_script()], + cwd=workspace, + env=environment, + text=True, + capture_output=True, + check=False, + ) + assert completed.returncode != 0 + assert "::error::Invalid Cloudflare Pages deployment input." in completed.stderr