From 466271241ee6e404c828e5ddb2696f924a94ca3e Mon Sep 17 00:00:00 2001 From: Yeela Date: Mon, 10 Aug 2026 14:43:34 +0300 Subject: [PATCH 1/6] feat: handle client payload oversized parsing --- action.yml | 81 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/action.yml b/action.yml index b2c31cf0..adc3511e 100644 --- a/action.yml +++ b/action.yml @@ -31,6 +31,73 @@ inputs: runs: using: composite steps: + # gitStream sends client_payload in one of three forms so that a PR whose context exceeds GitHub's + # 65,535-char input limit still runs: plain JSON, base64(gzip), or a small reference to a payload + # stashed server-side. The rules engine resolves all three on its own, but the steps below read + # fields out of the payload in YAML expressions, which are evaluated long before the engine starts — + # so resolve just those fields here. Must stay ahead of every step that consumes them. + # CLIENT_PAYLOAD itself is passed through untouched, keeping this step's outputs small. + - name: Resolve payload fields + id: payload-fields + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PAYLOAD_ARG: ${{ inputs.client_payload }} + with: + script: | + const { gunzipSync } = require('zlib'); + const PAYLOAD_FETCH_TIMEOUT_MS = 10000; + const OVERSIZED_PAYLOAD_REFERENCE = 'oversized-payload-reference'; + const inflate = (value) => { + const buffer = Buffer.from(value, 'base64'); + if (buffer.length < 2 || buffer[0] !== 0x1f || buffer[1] !== 0x8b) { + return null; + } + return gunzipSync(buffer).toString('utf8'); + }; + + const parsePayload = (value) => { + const parsed = JSON.parse(value); + return typeof parsed === 'string' ? JSON.parse(parsed) : parsed; + }; + + const resolve = async (raw) => { + if (raw.includes(OVERSIZED_PAYLOAD_REFERENCE)) { + const reference = parsePayload(raw); + if (reference && reference.type === OVERSIZED_PAYLOAD_REFERENCE) { + const response = await fetch(reference.payloadUrl, { + headers: { Authorization: `Bearer ${reference.resolverToken}` }, + signal: AbortSignal.timeout(PAYLOAD_FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`stashed payload fetch returned ${response.status}`); + } + const body = await response.text(); + return { mode: 'reference', payload: parsePayload(inflate(body) ?? body) }; + } + } + const inflated = inflate(raw); + if (inflated !== null) { + return { mode: 'compressed', payload: parsePayload(inflated) }; + } + return { mode: 'plain', payload: parsePayload(raw) }; + }; + + try { + const { mode, payload } = await resolve(process.env.PAYLOAD_ARG || ''); + core.info(`client_payload mode=${mode}`); + const hasCmRepo = payload.hasCmRepo === true; + core.setOutput('github_token', payload.githubToken || ''); + core.setOutput('url', payload.headHttpUrl || payload.repoUrl || ''); + core.setOutput('has_cm_repo', String(hasCmRepo)); + core.setOutput('cm_repository', hasCmRepo ? `${payload.owner}/${payload.cmRepo}` : ''); + core.setOutput('cm_repo_ref', payload.cmRepoRef || ''); + core.setOutput('has_cm_org', String(payload.hasCmOrg === true)); + core.setOutput('cm_org_ref', payload.cmOrgRef || ''); + } catch (err) { + core.error(`Failed resolving client payload: ${err}`); + process.exit(1); + } + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 20.12.2 @@ -47,7 +114,7 @@ runs: repository: ${{ inputs.full_repository }} ref: ${{ inputs.base_ref }} path: gitstream/repo/ - token: ${{ fromJSON(fromJSON(inputs.client_payload)).githubToken || github.token }} + token: ${{ steps.payload-fields.outputs.github_token || github.token }} - name: Escape single quotes id: safe-strings @@ -56,7 +123,7 @@ runs: BASE_REF_ARG: ${{ inputs.base_ref }} HEAD_REF_ARG: ${{ inputs.head_ref }} PAYLOAD_ARG: ${{ inputs.client_payload }} - URL_ARG: ${{ fromJSON(fromJSON(inputs.client_payload)).headHttpUrl || fromJSON(fromJSON(inputs.client_payload)).repoUrl }} + URL_ARG: ${{ steps.payload-fields.outputs.url }} with: script: | try { @@ -97,19 +164,19 @@ runs: - name: Checkout cm repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - if: ${{ fromJSON(fromJSON(inputs.client_payload)).hasCmRepo == true }} + if: ${{ steps.payload-fields.outputs.has_cm_repo == 'true' }} with: - repository: '${{ fromJSON(fromJSON(inputs.client_payload)).owner }}/${{ fromJSON(fromJSON(inputs.client_payload)).cmRepo }}' - ref: ${{ fromJSON(fromJSON(inputs.client_payload)).cmRepoRef }} + repository: ${{ steps.payload-fields.outputs.cm_repository }} + ref: ${{ steps.payload-fields.outputs.cm_repo_ref }} path: gitstream/cm/ fetch-depth: 1 - name: Checkout cm org uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - if: ${{ fromJSON(fromJSON(inputs.client_payload)).hasCmOrg == true }} + if: ${{ steps.payload-fields.outputs.has_cm_org == 'true' }} with: repository: 'cm/cm' - ref: ${{ fromJSON(fromJSON(inputs.client_payload)).cmOrgRef }} + ref: ${{ steps.payload-fields.outputs.cm_org_ref }} path: gitstream/cm/ fetch-depth: 1 From 6c683fc894b40c9f32520618848de8f5278822eb Mon Sep 17 00:00:00 2001 From: Yeela Date: Mon, 10 Aug 2026 17:34:37 +0300 Subject: [PATCH 2/6] fix: mask the installation token from later steps' env dumps client_payload carries githubToken, and every step that takes the payload as env logs it in plaintext in its own env dump. Registering it as a secret from the first step that parses the payload masks those occurrences. Co-Authored-By: Claude Opus 5 (1M context) --- action.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/action.yml b/action.yml index adc3511e..3b0fa737 100644 --- a/action.yml +++ b/action.yml @@ -85,6 +85,14 @@ runs: try { const { mode, payload } = await resolve(process.env.PAYLOAD_ARG || ''); core.info(`client_payload mode=${mode}`); + + // The installation token travels inside client_payload, and every later step that takes the + // payload as env logs it in plaintext in its own env dump. Registering it here — from the + // first step that has it in hand — masks those. This step's own dump is already written by + // the time the script runs, so one occurrence remains. + if (payload.githubToken) { + core.setSecret(payload.githubToken); + } const hasCmRepo = payload.hasCmRepo === true; core.setOutput('github_token', payload.githubToken || ''); core.setOutput('url', payload.headHttpUrl || payload.repoUrl || ''); From a05e457e34a6ada17f12009786650bd10ece7f1c Mon Sep 17 00:00:00 2001 From: Yeela Date: Mon, 10 Aug 2026 17:35:38 +0300 Subject: [PATCH 3/6] fix: remove redundant comments --- action.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/action.yml b/action.yml index 3b0fa737..4e3e57d9 100644 --- a/action.yml +++ b/action.yml @@ -86,10 +86,6 @@ runs: const { mode, payload } = await resolve(process.env.PAYLOAD_ARG || ''); core.info(`client_payload mode=${mode}`); - // The installation token travels inside client_payload, and every later step that takes the - // payload as env logs it in plaintext in its own env dump. Registering it here — from the - // first step that has it in hand — masks those. This step's own dump is already written by - // the time the script runs, so one occurrence remains. if (payload.githubToken) { core.setSecret(payload.githubToken); } From 8287a5b0803c51cc8f7361974a06e5ee69d3dda3 Mon Sep 17 00:00:00 2001 From: Yeela Date: Mon, 10 Aug 2026 18:26:40 +0300 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20mask?= =?UTF-8?q?=20resolver=20token,=20fail=20via=20setFailed,=20clearer=20gzip?= =?UTF-8?q?=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core.setSecret on reference.resolverToken before it goes into the Authorization header, so it is masked like the installation token - core.setFailed instead of core.error + process.exit(1), so the step fails through the runner's normal path without risking truncated log output - gunzipSync wrapped so a corrupt blob reports "gzip decompression failed: ..." rather than a bare zlib message - shortened the step comment per review Co-Authored-By: Claude Opus 5 (1M context) --- action.yml | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/action.yml b/action.yml index 4e3e57d9..9bc435e9 100644 --- a/action.yml +++ b/action.yml @@ -31,12 +31,9 @@ inputs: runs: using: composite steps: - # gitStream sends client_payload in one of three forms so that a PR whose context exceeds GitHub's - # 65,535-char input limit still runs: plain JSON, base64(gzip), or a small reference to a payload - # stashed server-side. The rules engine resolves all three on its own, but the steps below read - # fields out of the payload in YAML expressions, which are evaluated long before the engine starts — - # so resolve just those fields here. Must stay ahead of every step that consumes them. - # CLIENT_PAYLOAD itself is passed through untouched, keeping this step's outputs small. + # client_payload arrives as plain JSON, base64(gzip), or a reference to a server-stashed payload. + # Later steps read fields off it in YAML expressions, which evaluate before the engine runs, so + # resolve them here. Must stay first. CLIENT_PAYLOAD itself is passed through untouched. - name: Resolve payload fields id: payload-fields uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -52,7 +49,11 @@ runs: if (buffer.length < 2 || buffer[0] !== 0x1f || buffer[1] !== 0x8b) { return null; } - return gunzipSync(buffer).toString('utf8'); + try { + return gunzipSync(buffer).toString('utf8'); + } catch (err) { + throw new Error(`gzip decompression failed: ${err.message}`); + } }; const parsePayload = (value) => { @@ -64,6 +65,7 @@ runs: if (raw.includes(OVERSIZED_PAYLOAD_REFERENCE)) { const reference = parsePayload(raw); if (reference && reference.type === OVERSIZED_PAYLOAD_REFERENCE) { + core.setSecret(reference.resolverToken); const response = await fetch(reference.payloadUrl, { headers: { Authorization: `Bearer ${reference.resolverToken}` }, signal: AbortSignal.timeout(PAYLOAD_FETCH_TIMEOUT_MS), @@ -86,11 +88,14 @@ runs: const { mode, payload } = await resolve(process.env.PAYLOAD_ARG || ''); core.info(`client_payload mode=${mode}`); - if (payload.githubToken) { - core.setSecret(payload.githubToken); + // The installation token rides inside client_payload, so mask it before it reaches an + // output or a later step's env dump. + const githubToken = payload.githubToken || ''; + if (githubToken) { + core.setSecret(githubToken); } const hasCmRepo = payload.hasCmRepo === true; - core.setOutput('github_token', payload.githubToken || ''); + core.setOutput('github_token', githubToken); core.setOutput('url', payload.headHttpUrl || payload.repoUrl || ''); core.setOutput('has_cm_repo', String(hasCmRepo)); core.setOutput('cm_repository', hasCmRepo ? `${payload.owner}/${payload.cmRepo}` : ''); @@ -98,8 +103,7 @@ runs: core.setOutput('has_cm_org', String(payload.hasCmOrg === true)); core.setOutput('cm_org_ref', payload.cmOrgRef || ''); } catch (err) { - core.error(`Failed resolving client payload: ${err}`); - process.exit(1); + core.setFailed(`Failed resolving client payload: ${err}`); } - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 From 889d30911b330a16d77ea386bc35ea54ae45ef83 Mon Sep 17 00:00:00 2001 From: Yeela Date: Mon, 10 Aug 2026 19:11:14 +0300 Subject: [PATCH 5/6] fix: cap inflated payload size to bound a decompression bomb gzip is asymmetric: a ~66KB base64 input inflates to 50MB and can exhaust the runner before parsing. gunzipSync now runs with maxOutputLength, which covers both the compressed input and the body fetched for a stashed payload, and reports a clear message instead of a bare zlib error when the cap trips. Co-Authored-By: Claude Opus 5 (1M context) --- action.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 9bc435e9..bf187c9e 100644 --- a/action.yml +++ b/action.yml @@ -44,14 +44,21 @@ runs: const { gunzipSync } = require('zlib'); const PAYLOAD_FETCH_TIMEOUT_MS = 10000; const OVERSIZED_PAYLOAD_REFERENCE = 'oversized-payload-reference'; + // Bounds a decompression bomb: gzip is asymmetric, so a ~66KB input can inflate to 50MB and + // exhaust the runner. The largest payload seen in production is ~1.4MB, so this leaves ample + // room while keeping the allocation finite. + const MAX_INFLATED_PAYLOAD_BYTES = 32 * 1024 * 1024; const inflate = (value) => { const buffer = Buffer.from(value, 'base64'); if (buffer.length < 2 || buffer[0] !== 0x1f || buffer[1] !== 0x8b) { return null; } try { - return gunzipSync(buffer).toString('utf8'); + return gunzipSync(buffer, { maxOutputLength: MAX_INFLATED_PAYLOAD_BYTES }).toString('utf8'); } catch (err) { + if (err.code === 'ERR_BUFFER_TOO_LARGE') { + throw new Error(`payload inflates beyond ${MAX_INFLATED_PAYLOAD_BYTES} bytes; refusing to expand it`); + } throw new Error(`gzip decompression failed: ${err.message}`); } }; From da56ab46de86fb0bd064374b0e91596eeeeca981 Mon Sep 17 00:00:00 2001 From: Yeela Date: Mon, 10 Aug 2026 19:22:17 +0300 Subject: [PATCH 6/6] fix: only fetch a stashed payload from the resolver's own origin reference.payloadUrl came straight from client_payload, so a crafted dispatch could aim the runner's fetch at any address - an SSRF primitive that matters most on self-hosted runners. The stash is served by the same host as the resolver, so the origin is now required to match inputs.resolver_url, which keeps prod, dev and self-hosted deployments working without hardcoding a domain. Co-Authored-By: Claude Opus 5 (1M context) --- action.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/action.yml b/action.yml index bf187c9e..ed1be5fd 100644 --- a/action.yml +++ b/action.yml @@ -39,6 +39,7 @@ runs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PAYLOAD_ARG: ${{ inputs.client_payload }} + RESOLVER_URL_ARG: ${{ inputs.resolver_url }} with: script: | const { gunzipSync } = require('zlib'); @@ -72,6 +73,14 @@ runs: if (raw.includes(OVERSIZED_PAYLOAD_REFERENCE)) { const reference = parsePayload(raw); if (reference && reference.type === OVERSIZED_PAYLOAD_REFERENCE) { + // The stash is served by the same host as the resolver, so require that origin rather + // than fetching whatever the payload names. Without this a crafted client_payload could + // aim the runner at an internal address, which matters most on self-hosted runners. + const expectedOrigin = new URL(process.env.RESOLVER_URL_ARG || '').origin; + const payloadOrigin = new URL(reference.payloadUrl).origin; + if (payloadOrigin !== expectedOrigin) { + throw new Error(`refusing to fetch stashed payload from ${payloadOrigin}; expected ${expectedOrigin}`); + } core.setSecret(reference.resolverToken); const response = await fetch(reference.payloadUrl, { headers: { Authorization: `Bearer ${reference.resolverToken}` },