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>
This commit is contained in:
John Oliver 2026-06-04 12:22:15 +00:00
parent b06c5c8844
commit 284c50b083
5 changed files with 119 additions and 17 deletions

View File

@ -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: '<https://api.adoptium.net/v3/versions?page=3>; type="application/json"; rel="next"'
},
'https://api.adoptium.net/v3/versions?page=3'
],
[{link: '<https://example.com/last?page=5>; 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([

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -201,6 +201,8 @@ export function getGitHubHttpHeaders(): OutgoingHttpHeaders {
return headers;
}
export const MAX_PAGINATION_PAGES = 1000;
export function getNextPageUrlFromLinkHeader(
headers?: Record<string, string | string[] | undefined>
): 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