From 284c50b083f0c586c37e3f65f2a89be1c6a17037 Mon Sep 17 00:00:00 2001 From: John Oliver <1615532+johnoliver@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:22:15 +0000 Subject: [PATCH] Address PR review: RFC-compliant Link parsing, SSRF validation, centralized constant - Make getNextPageUrlFromLinkHeader RFC 8288 compliant by splitting link-values and checking for rel=next anywhere in the parameters, not just as the first parameter after the semicolon. - Add validatePaginationUrl utility to reject pagination URLs that point to unexpected origins (SSRF mitigation). - Centralize MAX_PAGINATION_PAGES in util.ts instead of duplicating across Adopt, Semeru, and Temurin installers. - Add tests for rel not being the first parameter, and for URL origin validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __tests__/util.test.ts | 44 +++++++++++++++++++++++++- src/distributions/adopt/installer.ts | 19 ++++++++--- src/distributions/semeru/installer.ts | 19 ++++++++--- src/distributions/temurin/installer.ts | 19 ++++++++--- src/util.ts | 35 +++++++++++++++++--- 5 files changed, 119 insertions(+), 17 deletions(-) diff --git a/__tests__/util.test.ts b/__tests__/util.test.ts index a0c11de3..d3746cc7 100644 --- a/__tests__/util.test.ts +++ b/__tests__/util.test.ts @@ -8,7 +8,8 @@ import { getVersionFromFileContent, isVersionSatisfies, isCacheFeatureAvailable, - isGhes + isGhes, + validatePaginationUrl } from '../src/util'; jest.mock('@actions/cache'); @@ -100,6 +101,12 @@ describe('getNextPageUrlFromLinkHeader', () => { }, 'https://example.com/next?page=2' ], + [ + { + link: '; type="application/json"; rel="next"' + }, + 'https://api.adoptium.net/v3/versions?page=3' + ], [{link: '; rel="last"'}, null], [undefined, null] ])('returns %s -> %s', (headers, expected) => { @@ -107,6 +114,41 @@ describe('getNextPageUrlFromLinkHeader', () => { }); }); +describe('validatePaginationUrl', () => { + it('accepts URL with matching origin', () => { + expect( + validatePaginationUrl( + 'https://api.adoptium.net/v3/assets?page=2', + 'https://api.adoptium.net' + ) + ).toBe(true); + }); + + it('rejects URL with different host', () => { + expect( + validatePaginationUrl( + 'https://evil.example.com/steal?data=1', + 'https://api.adoptium.net' + ) + ).toBe(false); + }); + + it('rejects URL with different protocol', () => { + expect( + validatePaginationUrl( + 'http://api.adoptium.net/v3/assets?page=2', + 'https://api.adoptium.net' + ) + ).toBe(false); + }); + + it('returns false for invalid URL', () => { + expect( + validatePaginationUrl('not-a-url', 'https://api.adoptium.net') + ).toBe(false); + }); +}); + describe('getVersionFromFileContent', () => { describe('.sdkmanrc', () => { it.each([ diff --git a/src/distributions/adopt/installer.ts b/src/distributions/adopt/installer.ts index e1200564..b6393f72 100644 --- a/src/distributions/adopt/installer.ts +++ b/src/distributions/adopt/installer.ts @@ -17,11 +17,11 @@ import { getNextPageUrlFromLinkHeader, getDownloadArchiveExtension, isVersionSatisfies, - renameWinArchive + renameWinArchive, + MAX_PAGINATION_PAGES, + validatePaginationUrl } from '../../util'; -const MAX_PAGINATION_PAGES = 1000; - export enum AdoptImplementation { Hotspot = 'Hotspot', OpenJ9 = 'OpenJ9' @@ -144,7 +144,18 @@ export class AdoptDistribution extends JavaBase { availableVersionsUrl ); const paginationPage = response.result; - availableVersionsUrl = getNextPageUrlFromLinkHeader(response.headers); + const nextUrl = getNextPageUrlFromLinkHeader(response.headers); + if ( + nextUrl && + !validatePaginationUrl(nextUrl, 'https://api.adoptopenjdk.net') + ) { + core.warning( + `Ignoring pagination link with unexpected origin: ${nextUrl}` + ); + availableVersionsUrl = null; + } else { + availableVersionsUrl = nextUrl; + } if (paginationPage === null || paginationPage.length === 0) { break; } diff --git a/src/distributions/semeru/installer.ts b/src/distributions/semeru/installer.ts index 14adc89f..a043f16e 100644 --- a/src/distributions/semeru/installer.ts +++ b/src/distributions/semeru/installer.ts @@ -10,7 +10,9 @@ import { getNextPageUrlFromLinkHeader, getDownloadArchiveExtension, isVersionSatisfies, - renameWinArchive + renameWinArchive, + MAX_PAGINATION_PAGES, + validatePaginationUrl } from '../../util'; import * as core from '@actions/core'; import * as tc from '@actions/tool-cache'; @@ -18,8 +20,6 @@ import fs from 'fs'; import path from 'path'; import {ISemeruAvailableVersions} from './models'; -const MAX_PAGINATION_PAGES = 1000; - const supportedArchitectures = [ 'x64', 'x86', @@ -174,7 +174,18 @@ export class SemeruDistribution extends JavaBase { availableVersionsUrl ); const paginationPage = response.result; - availableVersionsUrl = getNextPageUrlFromLinkHeader(response.headers); + const nextUrl = getNextPageUrlFromLinkHeader(response.headers); + if ( + nextUrl && + !validatePaginationUrl(nextUrl, 'https://api.adoptopenjdk.net') + ) { + core.warning( + `Ignoring pagination link with unexpected origin: ${nextUrl}` + ); + availableVersionsUrl = null; + } else { + availableVersionsUrl = nextUrl; + } if (paginationPage === null || paginationPage.length === 0) { break; } diff --git a/src/distributions/temurin/installer.ts b/src/distributions/temurin/installer.ts index 225ced96..4f56ff2e 100644 --- a/src/distributions/temurin/installer.ts +++ b/src/distributions/temurin/installer.ts @@ -17,11 +17,11 @@ import { getNextPageUrlFromLinkHeader, getDownloadArchiveExtension, isVersionSatisfies, - renameWinArchive + renameWinArchive, + MAX_PAGINATION_PAGES, + validatePaginationUrl } from '../../util'; -const MAX_PAGINATION_PAGES = 1000; - export enum TemurinImplementation { Hotspot = 'Hotspot' } @@ -142,7 +142,18 @@ export class TemurinDistribution extends JavaBase { availableVersionsUrl ); const paginationPage = response.result; - availableVersionsUrl = getNextPageUrlFromLinkHeader(response.headers); + const nextUrl = getNextPageUrlFromLinkHeader(response.headers); + if ( + nextUrl && + !validatePaginationUrl(nextUrl, 'https://api.adoptium.net') + ) { + core.warning( + `Ignoring pagination link with unexpected origin: ${nextUrl}` + ); + availableVersionsUrl = null; + } else { + availableVersionsUrl = nextUrl; + } if (paginationPage === null || paginationPage.length === 0) { break; diff --git a/src/util.ts b/src/util.ts index 43c004ec..3b507214 100644 --- a/src/util.ts +++ b/src/util.ts @@ -201,6 +201,8 @@ export function getGitHubHttpHeaders(): OutgoingHttpHeaders { return headers; } +export const MAX_PAGINATION_PAGES = 1000; + export function getNextPageUrlFromLinkHeader( headers?: Record ): string | null { @@ -216,11 +218,36 @@ export function getNextPageUrlFromLinkHeader( const normalizedLinkHeader = Array.isArray(linkHeader) ? linkHeader.join(',') : linkHeader; - const nextLinkMatch = normalizedLinkHeader.match( - /<([^>]+)>\s*;\s*rel="?next"?/i - ); - return nextLinkMatch?.[1] ?? null; + // Split into individual link-values and find the one with rel="next" + // RFC 8288 allows rel to appear anywhere among the parameters + const linkValues = normalizedLinkHeader.split(/,(?=\s*<)/); + for (const linkValue of linkValues) { + const urlMatch = linkValue.match(/<([^>]+)>/); + if (!urlMatch) continue; + + const params = linkValue.slice(urlMatch[0].length); + if (/;\s*rel="?next"?/i.test(params)) { + return urlMatch[1]; + } + } + + return null; +} + +export function validatePaginationUrl( + url: string, + allowedOrigin: string +): boolean { + try { + const parsed = new URL(url); + const allowed = new URL(allowedOrigin); + return ( + parsed.protocol === allowed.protocol && parsed.host === allowed.host + ); + } catch { + return false; + } } // Rename archive to add extension because after downloading