From 466271241ee6e404c828e5ddb2696f924a94ca3e Mon Sep 17 00:00:00 2001 From: Yeela Date: Mon, 10 Aug 2026 14:43:34 +0300 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] =?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/9] 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/9] 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}` }, From 41a1ff743d96e9808661fcf6c223178299e7147a Mon Sep 17 00:00:00 2001 From: Yeela Date: Thu, 13 Aug 2026 09:51:56 +0300 Subject: [PATCH 7/9] feat: implement payload resolution logic and tests for client_payload handling --- __tests__/resolve-payload-fields.test.ts | 205 +++++++++++++++++++++++ action.yml | 89 +--------- scripts/resolve-payload-fields.js | 153 +++++++++++++++++ 3 files changed, 366 insertions(+), 81 deletions(-) create mode 100644 __tests__/resolve-payload-fields.test.ts create mode 100644 scripts/resolve-payload-fields.js diff --git a/__tests__/resolve-payload-fields.test.ts b/__tests__/resolve-payload-fields.test.ts new file mode 100644 index 00000000..f708df6d --- /dev/null +++ b/__tests__/resolve-payload-fields.test.ts @@ -0,0 +1,205 @@ +import { gzipSync } from 'zlib' + +/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ +const { run, toStepOutputs } = require('../scripts/resolve-payload-fields.js') +/* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ + +const RESOLVER_URL = 'https://resolver.example.com/api' + +const payload = { + githubToken: 'ghs_token', + headHttpUrl: 'https://github.com/acme/repo.git', + repoUrl: 'https://github.com/acme/other.git', + owner: 'acme', + hasCmRepo: true, + cmRepo: 'cm-repo', + cmRepoRef: 'main', + hasCmOrg: false, + cmOrgRef: '' +} + +interface Core { + info: jest.Mock + setFailed: jest.Mock + setSecret: jest.Mock + setOutput: jest.Mock +} + +const createCore = (): Core => ({ + info: jest.fn(), + setFailed: jest.fn(), + setSecret: jest.fn(), + setOutput: jest.fn() +}) + +const outputsOf = (core: Core): Record => + Object.fromEntries(core.setOutput.mock.calls) + +const runWith = async (clientPayload: string): Promise => { + const core = createCore() + await run({ core, clientPayload, resolverUrl: RESOLVER_URL }) + return core +} + +describe('toStepOutputs', () => { + it('maps payload fields to string outputs', () => { + expect(toStepOutputs(payload)).toEqual({ + github_token: 'ghs_token', + url: 'https://github.com/acme/repo.git', + has_cm_repo: 'true', + cm_repository: 'acme/cm-repo', + cm_repo_ref: 'main', + has_cm_org: 'false', + cm_org_ref: '' + }) + }) + + it('falls back to repoUrl and blanks the cm repo when absent', () => { + expect( + toStepOutputs({ repoUrl: 'https://github.com/acme/other.git' }) + ).toEqual({ + github_token: '', + url: 'https://github.com/acme/other.git', + has_cm_repo: 'false', + cm_repository: '', + cm_repo_ref: '', + has_cm_org: 'false', + cm_org_ref: '' + }) + }) +}) + +describe('run', () => { + it('resolves a plain JSON payload', async () => { + const core = await runWith(JSON.stringify(payload)) + + expect(core.setFailed).not.toHaveBeenCalled() + expect(core.info).toHaveBeenCalledWith('client_payload mode=plain') + expect(outputsOf(core).url).toBe('https://github.com/acme/repo.git') + }) + + it('resolves a double-encoded JSON payload', async () => { + const core = await runWith(JSON.stringify(JSON.stringify(payload))) + + expect(core.setFailed).not.toHaveBeenCalled() + expect(outputsOf(core).cm_repository).toBe('acme/cm-repo') + }) + + it('inflates a gzipped payload', async () => { + const compressed = gzipSync(JSON.stringify(payload)).toString('base64') + const core = await runWith(compressed) + + expect(core.setFailed).not.toHaveBeenCalled() + expect(core.info).toHaveBeenCalledWith('client_payload mode=compressed') + expect(outputsOf(core).cm_repo_ref).toBe('main') + }) + + it('masks the github token', async () => { + const core = await runWith(JSON.stringify(payload)) + + expect(core.setSecret).toHaveBeenCalledWith('ghs_token') + }) + + it('fails rather than inflating a decompression bomb', async () => { + const bomb = gzipSync(Buffer.alloc(64 * 1024 * 1024, 0x61)).toString( + 'base64' + ) + const core = await runWith(bomb) + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('refusing to expand it') + ) + }) + + it('fails on a payload that is not valid JSON', async () => { + const core = await runWith('not json') + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('Failed resolving client payload') + ) + }) +}) + +describe('run with an oversized-payload reference', () => { + const reference = { + type: 'oversized-payload-reference', + payloadUrl: 'https://resolver.example.com/payloads/1', + resolverToken: 'resolver_token' + } + + const mockFetch = (response: Partial): jest.Mock => { + const fetchMock = jest.fn().mockResolvedValue(response) + global.fetch = fetchMock + return fetchMock + } + + it('fetches the stashed payload from the resolver origin', async () => { + const fetchMock = mockFetch({ + ok: true, + text: async () => JSON.stringify(payload) + }) + + const core = await runWith(JSON.stringify(reference)) + + expect(core.setFailed).not.toHaveBeenCalled() + expect(core.info).toHaveBeenCalledWith('client_payload mode=reference') + expect(core.setSecret).toHaveBeenCalledWith('resolver_token') + expect(fetchMock).toHaveBeenCalledWith( + reference.payloadUrl, + expect.objectContaining({ + headers: { Authorization: 'Bearer resolver_token' } + }) + ) + expect(outputsOf(core).cm_repository).toBe('acme/cm-repo') + }) + + it('inflates a stashed payload that is gzipped', async () => { + mockFetch({ + ok: true, + text: async () => gzipSync(JSON.stringify(payload)).toString('base64') + }) + + const core = await runWith(JSON.stringify(reference)) + + expect(core.setFailed).not.toHaveBeenCalled() + expect(outputsOf(core).cm_repo_ref).toBe('main') + }) + + it('refuses an origin other than the resolver', async () => { + const fetchMock = mockFetch({ ok: true, text: async () => '{}' }) + + const core = await runWith( + JSON.stringify({ + ...reference, + payloadUrl: 'http://169.254.169.254/latest/meta-data' + }) + ) + + expect(fetchMock).not.toHaveBeenCalled() + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('refusing to fetch stashed payload') + ) + }) + + it('fails when the stash responds with an error', async () => { + mockFetch({ ok: false, status: 404 }) + + const core = await runWith(JSON.stringify(reference)) + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('stashed payload fetch returned 404') + ) + }) + + it('treats a payload that merely mentions the marker as a regular payload', async () => { + const fetchMock = mockFetch({ ok: true, text: async () => '{}' }) + + const core = await runWith( + JSON.stringify({ ...payload, cmRepoRef: 'oversized-payload-reference' }) + ) + + expect(fetchMock).not.toHaveBeenCalled() + expect(core.info).toHaveBeenCalledWith('client_payload mode=plain') + expect(outputsOf(core).cm_repo_ref).toBe('oversized-payload-reference') + }) +}) diff --git a/action.yml b/action.yml index ed1be5fd..b13ca13a 100644 --- a/action.yml +++ b/action.yml @@ -32,95 +32,22 @@ runs: using: composite steps: # 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. + # See scripts/resolve-payload-fields.js for the resolution logic and its outputs. - name: Resolve payload fields id: payload-fields uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: + ACTION_PATH: ${{ github.action_path }} PAYLOAD_ARG: ${{ inputs.client_payload }} RESOLVER_URL_ARG: ${{ inputs.resolver_url }} with: script: | - 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, { 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}`); - } - }; - - 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) { - // 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}` }, - 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}`); - - // 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', 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.setFailed(`Failed resolving client payload: ${err}`); - } + const { run } = require(`${process.env.ACTION_PATH}/scripts/resolve-payload-fields.js`); + await run({ + core, + clientPayload: process.env.PAYLOAD_ARG, + resolverUrl: process.env.RESOLVER_URL_ARG, + }); - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: diff --git a/scripts/resolve-payload-fields.js b/scripts/resolve-payload-fields.js new file mode 100644 index 00000000..d39bdba2 --- /dev/null +++ b/scripts/resolve-payload-fields.js @@ -0,0 +1,153 @@ +/** + * 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 + * + * Run from the `Resolve payload fields` step via actions/github-script. + */ + +const { gunzipSync } = require('zlib') + +const OVERSIZED_PAYLOAD_REFERENCE = 'oversized-payload-reference' +const PAYLOAD_FETCH_TIMEOUT_MS = 10000 + +// 32MB +const MAX_INFLATED_PAYLOAD_BYTES = 32 * 1024 * 1024 + +/** + * @param {string} value + * @returns {string | null} the inflated text, or null if `value` is not gzip + */ +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`, + { cause: err } + ) + } + throw new Error(`gzip decompression failed: ${err.message}`, { cause: err }) + } +} + +/** Parses JSON that may have been encoded twice. */ +function parsePayload(value) { + const parsed = JSON.parse(value) + return typeof parsed === 'string' ? JSON.parse(parsed) : parsed +} + +/** + * @returns {object | null} the stash reference, or null for a regular payload + */ +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 +} + +/** + * 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. + */ +function assertResolverOrigin(payloadUrl, resolverUrl) { + const expectedOrigin = new URL(resolverUrl || '').origin + const payloadOrigin = new URL(payloadUrl).origin + if (payloadOrigin !== expectedOrigin) { + throw new Error( + `refusing to fetch stashed payload from ${payloadOrigin}; expected ${expectedOrigin}` + ) + } +} + +async function fetchStashedPayload(reference, resolverUrl, core) { + assertResolverOrigin(reference.payloadUrl, resolverUrl) + core.setSecret(reference.resolverToken) + 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 parsePayload(inflateIfGzipped(body) ?? body) +} + +/** + * @returns {Promise<{ mode: string, payload: object }>} + */ +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. 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 || '', + has_cm_org: String(payload.hasCmOrg === true), + cm_org_ref: payload.cmOrgRef || '' + } +} + +async function run({ core, clientPayload, resolverUrl }) { + try { + const { mode, payload } = await resolvePayload( + clientPayload || '', + resolverUrl, + core + ) + core.info(`client_payload mode=${mode}`) + + const outputs = toStepOutputs(payload) + + 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 = { + run, + toStepOutputs +} From c70a144b47ec06b84c344c6d482861408c738dfe Mon Sep 17 00:00:00 2001 From: Yeela Date: Thu, 13 Aug 2026 11:41:18 +0300 Subject: [PATCH 8/9] fix: ensure stashed payload is fetched from the resolver's origin to prevent internal address targeting --- __tests__/resolve-payload-fields.test.ts | 19 +++++++++++++++++- scripts/resolve-payload-fields.js | 25 ++++++++++++------------ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/__tests__/resolve-payload-fields.test.ts b/__tests__/resolve-payload-fields.test.ts index f708df6d..d92c97fc 100644 --- a/__tests__/resolve-payload-fields.test.ts +++ b/__tests__/resolve-payload-fields.test.ts @@ -145,7 +145,7 @@ describe('run with an oversized-payload reference', () => { expect(core.info).toHaveBeenCalledWith('client_payload mode=reference') expect(core.setSecret).toHaveBeenCalledWith('resolver_token') expect(fetchMock).toHaveBeenCalledWith( - reference.payloadUrl, + new URL(reference.payloadUrl), expect.objectContaining({ headers: { Authorization: 'Bearer resolver_token' } }) @@ -181,6 +181,23 @@ describe('run with an oversized-payload reference', () => { ) }) + it('sends the request to the resolver host, not one named by the path', async () => { + const fetchMock = mockFetch({ + ok: true, + text: async () => JSON.stringify(payload) + }) + + await runWith( + JSON.stringify({ + ...reference, + payloadUrl: 'https://resolver.example.com//evil.example.com/x' + }) + ) + + const [requested] = fetchMock.mock.calls[0] + expect(requested.host).toBe('resolver.example.com') + }) + it('fails when the stash responds with an error', async () => { mockFetch({ ok: false, status: 404 }) diff --git a/scripts/resolve-payload-fields.js b/scripts/resolve-payload-fields.js index d39bdba2..3f4ae4cd 100644 --- a/scripts/resolve-payload-fields.js +++ b/scripts/resolve-payload-fields.js @@ -62,26 +62,25 @@ function readStashReference(raw) { return parsed && parsed.type === OVERSIZED_PAYLOAD_REFERENCE ? parsed : null } -/** - * 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. - */ -function assertResolverOrigin(payloadUrl, resolverUrl) { - const expectedOrigin = new URL(resolverUrl || '').origin - const payloadOrigin = new URL(payloadUrl).origin - if (payloadOrigin !== expectedOrigin) { +// Builds the stash URL on the resolver's own origin. +function stashUrl(payloadUrl, resolverUrl) { + const resolverOrigin = new URL(resolverUrl || '').origin + const requested = new URL(payloadUrl) + if (requested.origin !== resolverOrigin) { throw new Error( - `refusing to fetch stashed payload from ${payloadOrigin}; expected ${expectedOrigin}` + `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) { - assertResolverOrigin(reference.payloadUrl, resolverUrl) + const url = stashUrl(reference.payloadUrl, resolverUrl) core.setSecret(reference.resolverToken) - const response = await fetch(reference.payloadUrl, { + const response = await fetch(url, { headers: { Authorization: `Bearer ${reference.resolverToken}` }, signal: AbortSignal.timeout(PAYLOAD_FETCH_TIMEOUT_MS) }) From ede3437900bcea144876191adc1674f22bdb8d31 Mon Sep 17 00:00:00 2001 From: Yeela Date: Thu, 13 Aug 2026 11:55:36 +0300 Subject: [PATCH 9/9] fix: handle missing resolver_url by throwing an error to prevent invalid payload origin validation --- __tests__/resolve-payload-fields.test.ts | 16 ++++++++++++++++ scripts/resolve-payload-fields.js | 7 ++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/__tests__/resolve-payload-fields.test.ts b/__tests__/resolve-payload-fields.test.ts index d92c97fc..c70bcd47 100644 --- a/__tests__/resolve-payload-fields.test.ts +++ b/__tests__/resolve-payload-fields.test.ts @@ -198,6 +198,22 @@ describe('run with an oversized-payload reference', () => { expect(requested.host).toBe('resolver.example.com') }) + it('fails clearly when resolver_url is not set', async () => { + const fetchMock = mockFetch({ ok: true, text: async () => '{}' }) + const core = createCore() + + await run({ + core, + clientPayload: JSON.stringify(reference), + resolverUrl: '' + }) + + expect(fetchMock).not.toHaveBeenCalled() + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('resolver_url is not set') + ) + }) + it('fails when the stash responds with an error', async () => { mockFetch({ ok: false, status: 404 }) diff --git a/scripts/resolve-payload-fields.js b/scripts/resolve-payload-fields.js index 3f4ae4cd..07558392 100644 --- a/scripts/resolve-payload-fields.js +++ b/scripts/resolve-payload-fields.js @@ -64,7 +64,12 @@ function readStashReference(raw) { // Builds the stash URL on the resolver's own origin. function stashUrl(payloadUrl, resolverUrl) { - const resolverOrigin = new URL(resolverUrl || '').origin + 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(