From 84fe0f4ee245fe4947e8d8742b85060704057621 Mon Sep 17 00:00:00 2001 From: Rod Date: Mon, 28 Sep 2026 12:31:00 -0600 Subject: [PATCH 1/2] Send the PR title with the verify request The approval page shows a request's title from the request row when PP cannot read the pull request itself (no GitHub App access to the repository in that workspace). The verify route has accepted an optional prTitle for a while; this action never sent one, so those pages fell back to "Authorize production deploy" with no description, even for a PR with a full title. The title reaches the script through env, never interpolated into it, since a PR title is attacker-controlled text, and reaches jq through --arg. PP accepts at most 300 characters and rejects null, so the value is trimmed to 300 and the key is left out when the title is empty. Checked locally with jq: a title with quotes and a newline round-trips as a JSON string; an empty title omits the key; a 400-character title is sent as 300. Co-Authored-By: Claude Fable 5.1 --- action.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index dbc95f8..7e6d512 100644 --- a/action.yml +++ b/action.yml @@ -100,6 +100,8 @@ runs: PP_POST_COMMENT: ${{ inputs.post-comment }} PP_REPOSITORY: ${{ github.repository }} PP_PR_NUMBER: ${{ github.event.pull_request.number }} + # Through env, never interpolated into the script: a PR title is attacker-controlled text. + PP_PR_TITLE: ${{ github.event.pull_request.title }} PP_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PP_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} PP_RUN_ID: ${{ github.run_id }} @@ -288,6 +290,7 @@ runs: --argjson failOnMissing "$FAIL_ON_MISSING_JSON" \ --argjson protectedPathsChanged "$PROTECTED_PATHS_CHANGED" \ --argjson changedFiles "$CHANGED_FILES_JSON" \ + --arg prTitle "${PP_PR_TITLE:-}" \ '{ scope: { repo: $repo, @@ -300,7 +303,11 @@ runs: failOnMissing: $failOnMissing, protectedPathsChanged: $protectedPathsChanged, changedFiles: $changedFiles - }') + } + # The PR title, so the approval page has one even when PP cannot + # read the pull request itself. PP accepts at most 300 characters + # and rejects null, so the key is trimmed or left out. + + (if ($prTitle | length) > 0 then { prTitle: $prTitle[0:300] } else {} end)') echo "" echo "═══════════════════════════════════════════════════════════" From 01b89e933dfa4dbfb050813c3257f7a555b80f3e Mon Sep 17 00:00:00 2001 From: Rod Date: Tue, 29 Sep 2026 15:30:42 -0600 Subject: [PATCH 2/2] Cap the PR title by UTF-16 length; test the title in the harness PP rejects a prTitle longer than 300 UTF-16 units. The first version cut at 300 code points, and an emoji counts as two units, so a long emoji-heavy title could exceed the limit and fail the gate closed with a validation error. The title is now cut to 300 code points when every code point is a single unit, else to 150 (at most two units each). test/run-tests.sh (from #69) now covers the title: quotes, a newline and a $(...) round-trip as a JSON string without being run; an empty title omits the key; a long ASCII title is cut to 300; a 200-emoji title is cut to 150 code points, 300 UTF-16 units; a push-mode deploy sends no title. Reverting to the code-point cap fails the emoji case. 77 passed locally (bash 3.2, jq 1.7.1). Co-Authored-By: Claude Opus 5.5 --- action.yml | 10 +++++++--- test/run-tests.sh | 21 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/action.yml b/action.yml index 58864cf..de545c8 100644 --- a/action.yml +++ b/action.yml @@ -421,9 +421,13 @@ runs: changedFiles: $changedFiles } # The PR title, so the approval page has one even when PP cannot - # read the pull request itself. PP accepts at most 300 characters - # and rejects null, so the key is trimmed or left out. - + (if ($prTitle | length) > 0 then { prTitle: $prTitle[0:300] } else {} end)') + # read the pull request itself. PP rejects null and more than 300 + # UTF-16 units, so the key is left out or cut: 300 code points when + # every one is a single unit, else 150 (at most two units each). An + # over-long title must not fail the gate closed. + + (if ($prTitle | length) == 0 then {} + elif ([$prTitle | explode[] | select(. > 65535)] | length) == 0 then { prTitle: $prTitle[0:300] } + else { prTitle: $prTitle[0:150] } end)') echo "" echo "═══════════════════════════════════════════════════════════" diff --git a/test/run-tests.sh b/test/run-tests.sh index 9bd476d..1725587 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -82,7 +82,7 @@ run_case() { export PP_REDEEM= PP_FAIL_ON_MISSING=true PP_PROTECTED_PATHS='^(deploy/|\.github/workflows/)' export PP_FAIL_OPEN_TIMEOUT=30 PP_FAIL_MODE=closed PP_PRODUCTION_ENVIRONMENTS=production,prod,live export PP_POST_COMMENT=false PP_REPOSITORY=Acme/Web - export PP_PR_NUMBER= PP_PR_HEAD_SHA= PP_PR_BASE_SHA= + export PP_PR_NUMBER= PP_PR_HEAD_SHA= PP_PR_BASE_SHA= PP_PR_TITLE= export PP_MODE=auto PP_EVENT_NAME=push PP_SHA=mergesha0000000000 PP_REF=refs/heads/main export PP_RUN_ID=4242 GH_TOKEN=ghs_test for assignment in "$@"; do export "$assignment"; done @@ -130,6 +130,7 @@ check "scope" "$(request .scope)" '{"repo":"acme/web","prNumber":12,"headSha":"a check "redeem" "$(request .redeem)" true check "failOnMissing" "$(request .failOnMissing)" true check "no draft lookup" "$(grep -c isDraft "$CASE_DIR/gh.log")" 0 +check "no title outside a PR event" "$(request 'has("prTitle")')" false PULLS="$MERGED" run_case "workflow_dispatch deploys merged PR" PP_EVENT_NAME=workflow_dispatch <<<"$VALID" check "exit" "$EXIT_CODE" 0 @@ -245,6 +246,24 @@ check "no PP call" "$(no_request)" none run_case "pr gate missing receipt, fail-on-missing false" "${PR_CTX[@]}" PP_FAIL_ON_MISSING=false <<<'{"valid": false, "errorCode": "RECEIPT_NOT_FOUND"}' check "exit" "$EXIT_CODE" 0 +# --- the PR title rides along, so PP can show it without reading GitHub ------ +utf16() { jq -r '[.prTitle | explode[] | if . > 65535 then 2 else 1 end] | add' "$CASE_DIR/request.json"; } + +run_case "PR title is sent as JSON, quotes and newlines intact" "${PR_CTX[@]}" "PP_PR_TITLE=$(printf 'Fix "auth" redirect\nloop $(id)')" <<<"$VALID" +check "exit" "$EXIT_CODE" 0 +check "title" "$(request .prTitle)" '"Fix \"auth\" redirect\nloop $(id)"' +check "scope unchanged" "$(request .scope)" '{"repo":"acme/web","prNumber":12,"headSha":"approvedhead12","env":"production","capability":"deploy:production"}' + +run_case "empty PR title is left out" "${PR_CTX[@]}" PP_PR_TITLE= <<<"$VALID" +check "no key" "$(request 'has("prTitle")')" false + +run_case "long PR title is cut to 300" "${PR_CTX[@]}" "PP_PR_TITLE=$(printf 'x%.0s' $(seq 1 400))" <<<"$VALID" +check "length" "$(request '.prTitle | length')" 300 + +run_case "emoji PR title stays within 300 UTF-16 units" "${PR_CTX[@]}" "PP_PR_TITLE=$(for i in $(seq 1 200); do printf '\360\237\232\200'; done)" <<<"$VALID" +check "code points" "$(request '.prTitle | length')" 150 +check "utf-16 units" "$(utf16)" 300 + echo "" echo "${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ]