diff --git a/action.yml b/action.yml index b2c31cf0..ed1be5fd 100644 --- a/action.yml +++ b/action.yml @@ -31,6 +31,97 @@ inputs: 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. + - name: Resolve payload fields + id: payload-fields + 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'); + 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}`); + } + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 20.12.2 @@ -47,7 +138,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 +147,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 +188,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