-
Notifications
You must be signed in to change notification settings - Fork 11
feat: handle client payload oversized parsing #566
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
4662712
feat: handle client payload oversized parsing
yeelali14 6c683fc
fix: mask the installation token from later steps' env dumps
yeelali14 a05e457
fix: remove redundant comments
yeelali14 8287a5b
fix: address review — mask resolver token, fail via setFailed, cleare…
yeelali14 889d309
fix: cap inflated payload size to bound a decompression bomb
yeelali14 da56ab4
fix: only fetch a stashed payload from the resolver's own origin
yeelali14 41a1ff7
feat: implement payload resolution logic and tests for client_payload…
yeelali14 c70a144
fix: ensure stashed payload is fetched from the resolver's origin to …
yeelali14 ede3437
fix: handle missing resolver_url by throwing an error to prevent inva…
yeelali14 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we already pass the
resolverUrlin theclientPayload, so maybe we can simplify it by 1 field.There was a problem hiding this comment.
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.resolverUrlonly 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_urlis trusted precisely because it comes from the workflow, not the payload. (Core treats it the same way:RULES_RESOLVER_URL || payload?.resolverUrl.)