diff --git a/__tests__/resolve-payload-fields.test.ts b/__tests__/resolve-payload-fields.test.ts new file mode 100644 index 00000000..c70bcd47 --- /dev/null +++ b/__tests__/resolve-payload-fields.test.ts @@ -0,0 +1,238 @@ +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( + new URL(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('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 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 }) + + 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 b2c31cf0..b13ca13a 100644 --- a/action.yml +++ b/action.yml @@ -31,6 +31,24 @@ inputs: runs: using: composite steps: + # client_payload arrives as plain JSON, base64(gzip), or a reference to a server-stashed payload. + # 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 { 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: node-version: 20.12.2 @@ -47,7 +65,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 +74,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 +115,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 diff --git a/scripts/resolve-payload-fields.js b/scripts/resolve-payload-fields.js new file mode 100644 index 00000000..07558392 --- /dev/null +++ b/scripts/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 + * + * 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 +} + +// Builds the stash URL on the resolver's own 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) +} + +/** + * @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 +}