From 4857bd873933278ba171b877d400fa5ecee87371 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 22:37:03 +0900 Subject: [PATCH 01/11] security(deploy-pages): declare minimal secret contract --- .github/workflows/deploy-pages.yml | 15 ++++- CHANGELOG.md | 2 + .../doctoring/deploy-pages-secret-contract.md | 43 +++++++++++++ tests/test_deploy_pages_secret_contract.py | 61 +++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 docs/doctoring/deploy-pages-secret-contract.md create mode 100644 tests/test_deploy_pages_secret_contract.py diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index f86b61402..c4cffbfd0 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -3,7 +3,7 @@ # 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`. +# GitHub Actions and callers map only these two declared values. # # 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,6 +40,13 @@ 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 @@ -57,7 +66,7 @@ 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 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..786f158cd --- /dev/null +++ b/docs/doctoring/deploy-pages-secret-contract.md @@ -0,0 +1,43 @@ +# Cloudflare Pages reusable-workflow secret contract + +## Decision + +The reusable Pages deployment accepts exactly two required secrets: +`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`. Callers map them explicitly: + +```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 }} +``` + +The workflow keeps `contents: read`, checks out the caller repository, and uses +the token only for Pages deployment and optional domain attachment. GitHub +rejects an invocation missing either required mapping before the job starts; the +runtime guard remains a value-free defense in depth check. + +## 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; do not restore blanket inheritance centrally. + +Acceptance requires workflow contract tests, syntax and supply-chain checks, +and one protected-main caller deployment proving that both required mappings +reach Wrangler without exposing either value. A missing-mapping negative +control must stop before deployment and must not print a credential. + +## 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 or +reintroduce blanket inheritance as a compatibility shortcut. Roll back the +central contract only for a confirmed GitHub reusable-workflow platform defect, +and preserve the two-name allowlist in the replacement transport. diff --git a/tests/test_deploy_pages_secret_contract.py b/tests/test_deploy_pages_secret_contract.py new file mode 100644 index 000000000..baedebf40 --- /dev/null +++ b/tests/test_deploy_pages_secret_contract.py @@ -0,0 +1,61 @@ +"""Least-privilege contract tests for the reusable Pages deployment workflow.""" + +import re +from pathlib import Path + + +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" +EXPECTED_SECRETS = {"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"} + + +def workflow_text() -> str: + """Return the authoritative reusable Pages workflow text.""" + return WORKFLOW_PATH.read_text(encoding="utf-8") + + +def test_deploy_pages_declares_only_required_cloudflare_secrets() -> None: + """Expose exactly the two Cloudflare values the reusable job consumes.""" + 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(re.findall(r"^ ([A-Z][A-Z0-9_]+):$", call_contract, re.MULTILINE)) + assert declared == EXPECTED_SECRETS + referenced = set(re.findall(r"secrets\.([A-Z][A-Z0-9_]+)", workflow)) + assert referenced == EXPECTED_SECRETS + + +def test_deploy_pages_examples_map_secrets_explicitly() -> None: + """Prevent central caller guidance from restoring blanket inheritance.""" + workflow = workflow_text() + policy = POLICY_PATH.read_text(encoding="utf-8") + + for authority in (workflow, policy): + assert "secrets: inherit" not in authority + assert "CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}" in authority + assert "CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" in authority + + +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 From c174270548a5c6f74bd69676cbca1a4de146023d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 23:44:10 +0900 Subject: [PATCH 02/11] docs(secrets): state reusable inheritance boundary --- .../doctoring/deploy-pages-secret-contract.md | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/doctoring/deploy-pages-secret-contract.md b/docs/doctoring/deploy-pages-secret-contract.md index 786f158cd..be9637878 100644 --- a/docs/doctoring/deploy-pages-secret-contract.md +++ b/docs/doctoring/deploy-pages-secret-contract.md @@ -2,8 +2,10 @@ ## Decision -The reusable Pages deployment accepts exactly two required secrets: -`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`. Callers map them explicitly: +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: @@ -17,17 +19,26 @@ jobs: 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. GitHub -rejects an invocation missing either required mapping before the job starts; the -runtime guard remains a value-free defense in depth check. +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. ## 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; do not restore blanket inheritance centrally. +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 one protected-main caller deployment proving that both required mappings @@ -40,4 +51,5 @@ 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 or reintroduce blanket inheritance as a compatibility shortcut. Roll back the central contract only for a confirmed GitHub reusable-workflow platform defect, -and preserve the two-name allowlist in the replacement transport. +and preserve the explicit two-name interface and caller policy in the +replacement transport. From 1d622d96507ab05a3f8b525f7adc49bce0343155 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 23:44:12 +0900 Subject: [PATCH 03/11] docs(cloudflare): replace inherited caller example --- infra/cloudflare/README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 From 213449d21efe5faeb358c2b108806d51dafb2d07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 23:44:13 +0900 Subject: [PATCH 04/11] test(secrets): enforce explicit examples and platform limit --- tests/test_deploy_pages_secret_contract.py | 38 ++++++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/tests/test_deploy_pages_secret_contract.py b/tests/test_deploy_pages_secret_contract.py index baedebf40..633d9fc4b 100644 --- a/tests/test_deploy_pages_secret_contract.py +++ b/tests/test_deploy_pages_secret_contract.py @@ -7,6 +7,7 @@ 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"} @@ -16,7 +17,7 @@ def workflow_text() -> str: def test_deploy_pages_declares_only_required_cloudflare_secrets() -> None: - """Expose exactly the two Cloudflare values the reusable job consumes.""" + """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 @@ -38,14 +39,37 @@ def test_deploy_pages_declares_only_required_cloudflare_secrets() -> None: def test_deploy_pages_examples_map_secrets_explicitly() -> None: - """Prevent central caller guidance from restoring blanket inheritance.""" + """Prevent executable central examples from restoring blanket inheritance.""" workflow = workflow_text() - policy = POLICY_PATH.read_text(encoding="utf-8") + documents = ( + POLICY_PATH.read_text(encoding="utf-8"), + INFRA_GUIDE_PATH.read_text(encoding="utf-8"), + ) + examples = [workflow] - for authority in (workflow, policy): - assert "secrets: inherit" not in authority - assert "CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}" in authority - assert "CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" in authority + for document in documents: + fenced_yaml = re.findall(r"```yaml\\n(.*?)\\n```", document, re.DOTALL) + assert fenced_yaml + examples.extend(fenced_yaml) + + for example in examples: + assert "secrets: inherit" not in example + assert "CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}" in example + assert "CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" in example + + +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: From a5e788c4fc2c0788fc94f6d478072f80e8487af8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 23:45:09 +0900 Subject: [PATCH 05/11] test(secrets): parse fenced caller examples correctly --- tests/test_deploy_pages_secret_contract.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_deploy_pages_secret_contract.py b/tests/test_deploy_pages_secret_contract.py index 633d9fc4b..f04f2af65 100644 --- a/tests/test_deploy_pages_secret_contract.py +++ b/tests/test_deploy_pages_secret_contract.py @@ -48,7 +48,11 @@ def test_deploy_pages_examples_map_secrets_explicitly() -> None: examples = [workflow] for document in documents: - fenced_yaml = re.findall(r"```yaml\\n(.*?)\\n```", document, re.DOTALL) + 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) From 7514fc4622ce13710111166c675bdadd9a9f1adb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 23:45:10 +0900 Subject: [PATCH 06/11] docs(secrets): wrap explicit-caller boundary --- docs/doctoring/deploy-pages-secret-contract.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/deploy-pages-secret-contract.md b/docs/doctoring/deploy-pages-secret-contract.md index be9637878..4ae151731 100644 --- a/docs/doctoring/deploy-pages-secret-contract.md +++ b/docs/doctoring/deploy-pages-secret-contract.md @@ -28,7 +28,8 @@ 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 +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. From 1e87aa7ecad8e5aeb33d5483039199fe081f45d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 23:59:40 +0900 Subject: [PATCH 07/11] docs(security): clarify reusable secret interface boundary --- .github/workflows/deploy-pages.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index c4cffbfd0..87e55d66b 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 and callers map only these two declared values. +# 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): # From 66be94b41fc27def6d022c84ecbb1b55228baf4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 07:46:52 +0900 Subject: [PATCH 08/11] test(security): require bounded Pages deployment inputs --- tests/test_deploy_pages_secret_contract.py | 151 ++++++++++++++++++++- 1 file changed, 149 insertions(+), 2 deletions(-) diff --git a/tests/test_deploy_pages_secret_contract.py b/tests/test_deploy_pages_secret_contract.py index f04f2af65..b7f0f7bad 100644 --- a/tests/test_deploy_pages_secret_contract.py +++ b/tests/test_deploy_pages_secret_contract.py @@ -1,8 +1,15 @@ -"""Least-privilege contract tests for the reusable Pages deployment workflow.""" +"""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" @@ -16,6 +23,49 @@ def workflow_text() -> str: 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() @@ -32,7 +82,9 @@ def test_deploy_pages_declares_only_required_cloudflare_secrets() -> None: ) assert secret_contract is not None assert "required: true" in secret_contract.group("body") - declared = set(re.findall(r"^ ([A-Z][A-Z0-9_]+):$", call_contract, re.MULTILINE)) + declared = set( + re.findall(r"^ ([A-Z][A-Z0-9_]+):$", call_contract, re.MULTILINE) + ) assert declared == EXPECTED_SECRETS referenced = set(re.findall(r"secrets\.([A-Z][A-Z0-9_]+)", workflow)) assert referenced == EXPECTED_SECRETS @@ -87,3 +139,98 @@ def test_deploy_pages_missing_secret_diagnostic_does_not_print_values() -> None: 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 From 6cdaf3f1bf45602c22d6ca8a6db81d341df9929c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 07:50:37 +0900 Subject: [PATCH 09/11] fix(security): validate Pages deployment inputs --- .github/workflows/deploy-pages.yml | 120 ++++++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 87e55d66b..118816bd7 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -53,7 +53,7 @@ permissions: jobs: deploy_pages: - name: Deploy ${{ inputs.project_name }} + name: Deploy Cloudflare Pages runs-on: ubuntu-latest steps: - name: Checkout caller repo @@ -70,21 +70,111 @@ jobs: 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" @@ -111,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" From f2bbccc2bd736813444d7939bfc1809375be8602 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 07:55:21 +0900 Subject: [PATCH 10/11] docs(security): record Pages input trust boundary --- .../doctoring/deploy-pages-secret-contract.md | 82 ++++++++++++++++--- 1 file changed, 69 insertions(+), 13 deletions(-) diff --git a/docs/doctoring/deploy-pages-secret-contract.md b/docs/doctoring/deploy-pages-secret-contract.md index 4ae151731..fda59fa19 100644 --- a/docs/doctoring/deploy-pages-secret-contract.md +++ b/docs/doctoring/deploy-pages-secret-contract.md @@ -1,4 +1,4 @@ -# Cloudflare Pages reusable-workflow secret contract +# Cloudflare Pages reusable-workflow secret and input contract ## Decision @@ -29,9 +29,42 @@ 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. +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 @@ -42,15 +75,38 @@ 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 one protected-main caller deployment proving that both required mappings -reach Wrangler without exposing either value. A missing-mapping negative -control must stop before deployment and must not print a credential. +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. -## Rollback +## 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 or -reintroduce blanket inheritance as a compatibility shortcut. Roll back the -central contract only for a confirmed GitHub reusable-workflow platform defect, -and preserve the explicit two-name interface and caller policy in the -replacement transport. +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 From e7825b137cc359f8522c08794854fe9f39606c4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 12:43:06 +0900 Subject: [PATCH 11/11] test(security): close reusable-secret YAML variant gaps --- tests/test_deploy_pages_secret_contract.py | 45 ++++++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/tests/test_deploy_pages_secret_contract.py b/tests/test_deploy_pages_secret_contract.py index b7f0f7bad..704bbe230 100644 --- a/tests/test_deploy_pages_secret_contract.py +++ b/tests/test_deploy_pages_secret_contract.py @@ -16,6 +16,12 @@ 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: @@ -82,14 +88,28 @@ def test_deploy_pages_declares_only_required_cloudflare_secrets() -> None: ) assert secret_contract is not None assert "required: true" in secret_contract.group("body") - declared = set( - re.findall(r"^ ([A-Z][A-Z0-9_]+):$", call_contract, re.MULTILINE) - ) + 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() @@ -109,11 +129,28 @@ def test_deploy_pages_examples_map_secrets_explicitly() -> None: examples.extend(fenced_yaml) for example in examples: - assert "secrets: inherit" not in example + 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())