Skip to content
Merged
238 changes: 238 additions & 0 deletions __tests__/resolve-payload-fields.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> =>
Object.fromEntries(core.setOutput.mock.calls)

const runWith = async (clientPayload: string): Promise<Core> => {
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<Response>): 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')
})
})
32 changes: 25 additions & 7 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we already pass the resolverUrl in the clientPayload, so maybe we can simplify it by 1 field.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch that the field exists, but I would keep this one. In the reference case we have to validate the URL before fetching, and payload.resolverUrl only exists after the fetch it is meant to authorize. More importantly, checking a payload-supplied URL against a payload-supplied origin means an attacker controls both sides of the comparison, so the guard becomes a no-op — inputs.resolver_url is trusted precisely because it comes from the workflow, not the payload. (Core treats it the same way: RULES_RESOLVER_URL || payload?.resolverUrl.)

});

- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 20.12.2
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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

Expand Down
Loading