From aae431e71b52f2fe7fe6dc3cef71e345882ce210 Mon Sep 17 00:00:00 2001 From: Yeela Date: Thu, 13 Aug 2026 13:58:06 +0300 Subject: [PATCH 1/3] fix: support shrunk client payloads on v1 client_payload arrives as plain JSON, base64(gzip), or a reference to a server-stashed payload. v1 resolved fields with fromJSON(fromJSON(github.event.inputs.client_payload)) in five places, which throws on the last two shapes before the step can run. Resolve those fields in a step instead, following the existing script.js idiom on this branch - a root-level module exported as a function of core, required with github.action_path. Only the five fields v1's action.yml actually consumes are emitted; v1 has no cm org checkout. CLIENT_PAYLOAD is still handed to the docker rules engine untouched. The engine resolves the envelope itself (gitstream/rules-engine:latest ships gitstream-core 2.1.301, which has that support), and passing the inflated payload through the runner env would risk E2BIG on large payloads. The deprecation notice, docker cache/pull/load steps and the engine invocation are untouched. Co-Authored-By: Claude Opus 5 (1M context) --- action.yml | 22 ++++-- resolve-payload-fields.js | 157 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 resolve-payload-fields.js diff --git a/action.yml b/action.yml index 51dfef22..f97f222c 100644 --- a/action.yml +++ b/action.yml @@ -35,6 +35,18 @@ runs: - name: Deprecation Notice shell: bash run: echo "::warning::gitstream-github-action@v1 is deprecated and will be disabled soon. Please upgrade to gitstream-github-action@v2. For more information, follow https://github.com/linear-b/gitstream-github-action/releases/tag/v2" + + # client_payload arrives as plain JSON, base64(gzip), or a reference to a server-stashed + # payload. See resolve-payload-fields.js for the resolution logic and its outputs. + - name: Resolve payload fields + id: payload-fields + uses: actions/github-script@v7 + env: + PAYLOAD_ARG: ${{ github.event.inputs.client_payload }} + RESOLVER_URL_ARG: ${{ github.event.inputs.resolver_url }} + with: + script: | + await require('${{ github.action_path }}/resolve-payload-fields.js')(core); - name: Create GitStream folder shell: bash run: | @@ -49,7 +61,7 @@ runs: repository: ${{ inputs.full_repository }} ref: ${{ github.event.inputs.base_ref }} path: "gitstream/repo/" - token: ${{ fromJSON(fromJSON(github.event.inputs.client_payload)).githubToken || github.token }} + token: ${{ steps.payload-fields.outputs.github_token || github.token }} - name: Escape single quotes id: safe-strings @@ -58,7 +70,7 @@ runs: BASE_REF_ARG: ${{ github.event.inputs.base_ref }} HEAD_REF_ARG: ${{ github.event.inputs.head_ref }} PAYLOAD_ARG: ${{ github.event.inputs.client_payload }} - URL_ARG: ${{ fromJSON(fromJSON(github.event.inputs.client_payload)).headHttpUrl || fromJSON(fromJSON(github.event.inputs.client_payload)).repoUrl }} + URL_ARG: ${{ steps.payload-fields.outputs.url }} with: script: | try { @@ -100,10 +112,10 @@ runs: - name: Checkout cm repo uses: actions/checkout@v4 - if: ${{ fromJSON(fromJSON(github.event.inputs.client_payload)).hasCmRepo == true }} + if: ${{ steps.payload-fields.outputs.has_cm_repo == 'true' }} with: - repository: "${{ fromJSON(fromJSON(github.event.inputs.client_payload)).owner }}/${{ fromJSON(fromJSON(github.event.inputs.client_payload)).cmRepo }}" - ref: ${{ fromJSON(fromJSON(github.event.inputs.client_payload)).cmRepoRef }} + repository: ${{ steps.payload-fields.outputs.cm_repository }} + ref: ${{ steps.payload-fields.outputs.cm_repo_ref }} path: "gitstream/cm/" - name: Get Docker cache key diff --git a/resolve-payload-fields.js b/resolve-payload-fields.js new file mode 100644 index 00000000..51f1d0d0 --- /dev/null +++ b/resolve-payload-fields.js @@ -0,0 +1,157 @@ +/** + * Resolves the `client_payload` input of action.yml into the individual fields + * that later steps consume. + * + * The payload reaches the action in one of three shapes: + * - plain JSON (possibly double-encoded as a JSON string) + * - compressed base64(gzip(JSON)) + * - reference small JSON pointing at a payload stashed on the resolver, + * used when the payload is too large to pass through GitHub + * + * Only the fields needed by YAML step expressions are resolved here. + * CLIENT_PAYLOAD itself is passed to the rules engine untouched - the engine + * inflates it, which keeps the large inflated payload off the runner's env. + */ + +const { gunzipSync } = require('zlib'); + +const OVERSIZED_PAYLOAD_REFERENCE = 'oversized-payload-reference'; +const PAYLOAD_FETCH_TIMEOUT_MS = 10000; + +// Bounds a decompression bomb: gzip is asymmetric, so a small input can inflate +// far enough to exhaust the runner. +const MAX_INFLATED_PAYLOAD_BYTES = 32 * 1024 * 1024; + +function inflateIfGzipped(value) { + const buffer = Buffer.from(value, 'base64'); + const isGzip = buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b; + if (!isGzip) { + return null; + } + try { + 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}`); + } +} + +// Parses JSON that may have been encoded twice. +function parsePayload(value) { + const parsed = JSON.parse(value); + return typeof parsed === 'string' ? JSON.parse(parsed) : parsed; +} + +function readStashReference(raw) { + // Cheap pre-check so a regular payload is only parsed once, further down. + if (!raw.includes(OVERSIZED_PAYLOAD_REFERENCE)) { + return null; + } + const parsed = parsePayload(raw); + return parsed && parsed.type === OVERSIZED_PAYLOAD_REFERENCE ? parsed : null; +} + +/** + * Builds the stash URL on the resolver's own origin. + * + * The reference names a URL to fetch, but it arrives inside client_payload, so + * that URL is attacker-influenced. Take only its path and query and re-attach + * them to resolver_url, so the host that reaches the network comes from the + * action's own input and cannot be steered at an internal address. The path is + * applied via the `pathname` setter: resolving it as a relative URL would let a + * `//host/...` path escape to another origin. + */ +function stashUrl(payloadUrl, resolverUrl) { + if (!resolverUrl) { + throw new Error( + 'resolver_url is not set; cannot validate the stashed payload origin', + ); + } + const resolverOrigin = new URL(resolverUrl).origin; + const requested = new URL(payloadUrl); + if (requested.origin !== resolverOrigin) { + throw new Error( + `refusing to fetch stashed payload from ${requested.origin}; expected ${resolverOrigin}`, + ); + } + const url = new URL(resolverOrigin); + url.pathname = requested.pathname; + url.search = requested.search; + return url; +} + +async function fetchStashedPayload(reference, resolverUrl, core) { + const url = stashUrl(reference.payloadUrl, resolverUrl); + core.setSecret(reference.resolverToken); + const response = await fetch(url, { + 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 parsePayload(inflateIfGzipped(body) ?? body); +} + +async function resolvePayload(raw, resolverUrl, core) { + const reference = readStashReference(raw); + if (reference) { + const payload = await fetchStashedPayload(reference, resolverUrl, core); + return { mode: 'reference', payload }; + } + const inflated = inflateIfGzipped(raw); + if (inflated !== null) { + return { mode: 'compressed', payload: parsePayload(inflated) }; + } + return { mode: 'plain', payload: parsePayload(raw) }; +} + +/** + * Maps a resolved payload to the step outputs v1's action.yml consumes. Output + * values are strings, so booleans are stringified to be compared as `== 'true'` + * in step conditions. + */ +function toStepOutputs(payload) { + const hasCmRepo = payload.hasCmRepo === true; + return { + github_token: payload.githubToken || '', + url: payload.headHttpUrl || payload.repoUrl || '', + has_cm_repo: String(hasCmRepo), + cm_repository: hasCmRepo ? `${payload.owner}/${payload.cmRepo}` : '', + cm_repo_ref: payload.cmRepoRef || '', + }; +} + +module.exports = async core => { + const { PAYLOAD_ARG, RESOLVER_URL_ARG } = process.env; + + try { + const { mode, payload } = await resolvePayload( + PAYLOAD_ARG || '', + RESOLVER_URL_ARG, + core, + ); + core.info(`client_payload mode=${mode}`); + + const outputs = toStepOutputs(payload); + // The installation token rides inside client_payload, so mask it before it + // reaches an output or a later step's env dump. + if (outputs.github_token) { + core.setSecret(outputs.github_token); + } + for (const [name, value] of Object.entries(outputs)) { + core.setOutput(name, value); + } + } catch (err) { + core.setFailed(`Failed resolving client payload: ${err}`); + } +}; + +module.exports.toStepOutputs = toStepOutputs; From a527e6df6ebfd996af23ee22a0a4c9883e01c0c1 Mon Sep 17 00:00:00 2001 From: Yeela Date: Thu, 13 Aug 2026 14:22:31 +0300 Subject: [PATCH 2/3] fix: read the wrapped compressed-payload envelope Same resolver change as develop and v2-lite: parse first and switch on the value of `type`, so the double-encoded compressed-payload envelope the GitHub trigger will send is inflated instead of being mistaken for a raw payload and silently resolving to empty fields. Both compressed forms stay supported permanently - Bitbucket has no run-name to protect and keeps sending the bare base64(gzip) form. Co-Authored-By: Claude Opus 5 (1M context) --- resolve-payload-fields.js | 42 ++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/resolve-payload-fields.js b/resolve-payload-fields.js index 51f1d0d0..1b645a6d 100644 --- a/resolve-payload-fields.js +++ b/resolve-payload-fields.js @@ -16,6 +16,7 @@ const { gunzipSync } = require('zlib'); const OVERSIZED_PAYLOAD_REFERENCE = 'oversized-payload-reference'; +const COMPRESSED_PAYLOAD = 'compressed-payload'; const PAYLOAD_FETCH_TIMEOUT_MS = 10000; // Bounds a decompression bomb: gzip is asymmetric, so a small input can inflate @@ -48,13 +49,15 @@ function parsePayload(value) { return typeof parsed === 'string' ? JSON.parse(parsed) : parsed; } -function readStashReference(raw) { - // Cheap pre-check so a regular payload is only parsed once, further down. - if (!raw.includes(OVERSIZED_PAYLOAD_REFERENCE)) { +// Returns the parsed value, or null when `raw` is not JSON at all - the bare +// base64(gzip) form, which has no envelope around it. +function tryParsePayload(raw) { + try { + const parsed = parsePayload(raw); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { return null; } - const parsed = parsePayload(raw); - return parsed && parsed.type === OVERSIZED_PAYLOAD_REFERENCE ? parsed : null; } /** @@ -100,16 +103,37 @@ async function fetchStashedPayload(reference, resolverUrl, core) { return parsePayload(inflateIfGzipped(body) ?? body); } +/** + * Resolves whichever shape the trigger sent. Both compressed forms are + * permanent, not a migration step: GitHub wraps the payload in an envelope so + * that `run-name`, which is evaluated before any step exists and so cannot be + * rescued from here, still parses. Bitbucket has no `run-name` and keeps + * sending the bare form. + */ async function resolvePayload(raw, resolverUrl, core) { - const reference = readStashReference(raw); - if (reference) { - const payload = await fetchStashedPayload(reference, resolverUrl, core); - return { mode: 'reference', payload }; + const parsed = tryParsePayload(raw); + if (parsed) { + // Switch on the *value* of `type`, never its presence: a raw payload may + // legitimately carry its own `type` (Bitbucket builds it from the webhook + // context), and must fall through to the raw branch below. + if (parsed.type === OVERSIZED_PAYLOAD_REFERENCE) { + const payload = await fetchStashedPayload(parsed, resolverUrl, core); + return { mode: 'reference', payload }; + } + if (parsed.type === COMPRESSED_PAYLOAD) { + const inflated = inflateIfGzipped(parsed.data || ''); + if (inflated === null) { + throw new Error(`${COMPRESSED_PAYLOAD} envelope carries no gzip data`); + } + return { mode: 'compressed-envelope', payload: parsePayload(inflated) }; + } + return { mode: 'plain', payload: parsed }; } const inflated = inflateIfGzipped(raw); if (inflated !== null) { return { mode: 'compressed', payload: parsePayload(inflated) }; } + // Not JSON and not gzip - let the JSON error describe what arrived. return { mode: 'plain', payload: parsePayload(raw) }; } From 595d75bffc0dcce400adf85162365912437b8bb8 Mon Sep 17 00:00:00 2001 From: Yeela Date: Thu, 13 Aug 2026 14:30:36 +0300 Subject: [PATCH 3/3] fix: name the offending URL when payloadUrl is not absolute Same diagnostic as develop and v2-lite. Co-Authored-By: Claude Opus 5 (1M context) --- resolve-payload-fields.js | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/resolve-payload-fields.js b/resolve-payload-fields.js index 1b645a6d..dc720198 100644 --- a/resolve-payload-fields.js +++ b/resolve-payload-fields.js @@ -64,11 +64,16 @@ function tryParsePayload(raw) { * Builds the stash URL on the resolver's own origin. * * The reference names a URL to fetch, but it arrives inside client_payload, so - * that URL is attacker-influenced. Take only its path and query and re-attach - * them to resolver_url, so the host that reaches the network comes from the - * action's own input and cannot be steered at an internal address. The path is - * applied via the `pathname` setter: resolving it as a relative URL would let a - * `//host/...` path escape to another origin. + * that URL is attacker-influenced. The host in `payloadUrl` is decorative and + * is discarded - always, not only when it disagrees. Only the path and query + * are carried over, re-attached to resolver_url, which comes from the workflow + * rather than the payload. That makes this structurally immune to being + * redirected through this field, so please do not "fix" it later by honouring + * the payload's host. + * + * The path is applied via the `pathname` setter rather than by resolving it as + * a relative URL: relative resolution would let a `//host/...` path escape to + * another origin. */ function stashUrl(payloadUrl, resolverUrl) { if (!resolverUrl) { @@ -77,7 +82,16 @@ function stashUrl(payloadUrl, resolverUrl) { ); } const resolverOrigin = new URL(resolverUrl).origin; - const requested = new URL(payloadUrl); + let requested; + try { + // The trigger always sends an absolute URL; both it and resolver_url are + // built from the same base, so a relative one means that base was empty. + requested = new URL(payloadUrl); + } catch { + throw new Error( + `stashed payload URL is not absolute: ${payloadUrl} - the resolver's public API base is probably unset`, + ); + } if (requested.origin !== resolverOrigin) { throw new Error( `refusing to fetch stashed payload from ${requested.origin}; expected ${resolverOrigin}`,