Skip to content

Commit 284c50b

Browse files
johnoliverCopilot
andcommitted
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>
1 parent b06c5c8 commit 284c50b

5 files changed

Lines changed: 119 additions & 17 deletions

File tree

__tests__/util.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
getVersionFromFileContent,
99
isVersionSatisfies,
1010
isCacheFeatureAvailable,
11-
isGhes
11+
isGhes,
12+
validatePaginationUrl
1213
} from '../src/util';
1314

1415
jest.mock('@actions/cache');
@@ -100,13 +101,54 @@ describe('getNextPageUrlFromLinkHeader', () => {
100101
},
101102
'https://example.com/next?page=2'
102103
],
104+
[
105+
{
106+
link: '<https://api.adoptium.net/v3/versions?page=3>; type="application/json"; rel="next"'
107+
},
108+
'https://api.adoptium.net/v3/versions?page=3'
109+
],
103110
[{link: '<https://example.com/last?page=5>; rel="last"'}, null],
104111
[undefined, null]
105112
])('returns %s -> %s', (headers, expected) => {
106113
expect(getNextPageUrlFromLinkHeader(headers)).toBe(expected);
107114
});
108115
});
109116

117+
describe('validatePaginationUrl', () => {
118+
it('accepts URL with matching origin', () => {
119+
expect(
120+
validatePaginationUrl(
121+
'https://api.adoptium.net/v3/assets?page=2',
122+
'https://api.adoptium.net'
123+
)
124+
).toBe(true);
125+
});
126+
127+
it('rejects URL with different host', () => {
128+
expect(
129+
validatePaginationUrl(
130+
'https://evil.example.com/steal?data=1',
131+
'https://api.adoptium.net'
132+
)
133+
).toBe(false);
134+
});
135+
136+
it('rejects URL with different protocol', () => {
137+
expect(
138+
validatePaginationUrl(
139+
'http://api.adoptium.net/v3/assets?page=2',
140+
'https://api.adoptium.net'
141+
)
142+
).toBe(false);
143+
});
144+
145+
it('returns false for invalid URL', () => {
146+
expect(
147+
validatePaginationUrl('not-a-url', 'https://api.adoptium.net')
148+
).toBe(false);
149+
});
150+
});
151+
110152
describe('getVersionFromFileContent', () => {
111153
describe('.sdkmanrc', () => {
112154
it.each([

src/distributions/adopt/installer.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ import {
1717
getNextPageUrlFromLinkHeader,
1818
getDownloadArchiveExtension,
1919
isVersionSatisfies,
20-
renameWinArchive
20+
renameWinArchive,
21+
MAX_PAGINATION_PAGES,
22+
validatePaginationUrl
2123
} from '../../util';
2224

23-
const MAX_PAGINATION_PAGES = 1000;
24-
2525
export enum AdoptImplementation {
2626
Hotspot = 'Hotspot',
2727
OpenJ9 = 'OpenJ9'
@@ -144,7 +144,18 @@ export class AdoptDistribution extends JavaBase {
144144
availableVersionsUrl
145145
);
146146
const paginationPage = response.result;
147-
availableVersionsUrl = getNextPageUrlFromLinkHeader(response.headers);
147+
const nextUrl = getNextPageUrlFromLinkHeader(response.headers);
148+
if (
149+
nextUrl &&
150+
!validatePaginationUrl(nextUrl, 'https://api.adoptopenjdk.net')
151+
) {
152+
core.warning(
153+
`Ignoring pagination link with unexpected origin: ${nextUrl}`
154+
);
155+
availableVersionsUrl = null;
156+
} else {
157+
availableVersionsUrl = nextUrl;
158+
}
148159
if (paginationPage === null || paginationPage.length === 0) {
149160
break;
150161
}

src/distributions/semeru/installer.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,16 @@ import {
1010
getNextPageUrlFromLinkHeader,
1111
getDownloadArchiveExtension,
1212
isVersionSatisfies,
13-
renameWinArchive
13+
renameWinArchive,
14+
MAX_PAGINATION_PAGES,
15+
validatePaginationUrl
1416
} from '../../util';
1517
import * as core from '@actions/core';
1618
import * as tc from '@actions/tool-cache';
1719
import fs from 'fs';
1820
import path from 'path';
1921
import {ISemeruAvailableVersions} from './models';
2022

21-
const MAX_PAGINATION_PAGES = 1000;
22-
2323
const supportedArchitectures = [
2424
'x64',
2525
'x86',
@@ -174,7 +174,18 @@ export class SemeruDistribution extends JavaBase {
174174
availableVersionsUrl
175175
);
176176
const paginationPage = response.result;
177-
availableVersionsUrl = getNextPageUrlFromLinkHeader(response.headers);
177+
const nextUrl = getNextPageUrlFromLinkHeader(response.headers);
178+
if (
179+
nextUrl &&
180+
!validatePaginationUrl(nextUrl, 'https://api.adoptopenjdk.net')
181+
) {
182+
core.warning(
183+
`Ignoring pagination link with unexpected origin: ${nextUrl}`
184+
);
185+
availableVersionsUrl = null;
186+
} else {
187+
availableVersionsUrl = nextUrl;
188+
}
178189
if (paginationPage === null || paginationPage.length === 0) {
179190
break;
180191
}

src/distributions/temurin/installer.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ import {
1717
getNextPageUrlFromLinkHeader,
1818
getDownloadArchiveExtension,
1919
isVersionSatisfies,
20-
renameWinArchive
20+
renameWinArchive,
21+
MAX_PAGINATION_PAGES,
22+
validatePaginationUrl
2123
} from '../../util';
2224

23-
const MAX_PAGINATION_PAGES = 1000;
24-
2525
export enum TemurinImplementation {
2626
Hotspot = 'Hotspot'
2727
}
@@ -142,7 +142,18 @@ export class TemurinDistribution extends JavaBase {
142142
availableVersionsUrl
143143
);
144144
const paginationPage = response.result;
145-
availableVersionsUrl = getNextPageUrlFromLinkHeader(response.headers);
145+
const nextUrl = getNextPageUrlFromLinkHeader(response.headers);
146+
if (
147+
nextUrl &&
148+
!validatePaginationUrl(nextUrl, 'https://api.adoptium.net')
149+
) {
150+
core.warning(
151+
`Ignoring pagination link with unexpected origin: ${nextUrl}`
152+
);
153+
availableVersionsUrl = null;
154+
} else {
155+
availableVersionsUrl = nextUrl;
156+
}
146157

147158
if (paginationPage === null || paginationPage.length === 0) {
148159
break;

src/util.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ export function getGitHubHttpHeaders(): OutgoingHttpHeaders {
201201
return headers;
202202
}
203203

204+
export const MAX_PAGINATION_PAGES = 1000;
205+
204206
export function getNextPageUrlFromLinkHeader(
205207
headers?: Record<string, string | string[] | undefined>
206208
): string | null {
@@ -216,11 +218,36 @@ export function getNextPageUrlFromLinkHeader(
216218
const normalizedLinkHeader = Array.isArray(linkHeader)
217219
? linkHeader.join(',')
218220
: linkHeader;
219-
const nextLinkMatch = normalizedLinkHeader.match(
220-
/<([^>]+)>\s*;\s*rel="?next"?/i
221-
);
222221

223-
return nextLinkMatch?.[1] ?? null;
222+
// Split into individual link-values and find the one with rel="next"
223+
// RFC 8288 allows rel to appear anywhere among the parameters
224+
const linkValues = normalizedLinkHeader.split(/,(?=\s*<)/);
225+
for (const linkValue of linkValues) {
226+
const urlMatch = linkValue.match(/<([^>]+)>/);
227+
if (!urlMatch) continue;
228+
229+
const params = linkValue.slice(urlMatch[0].length);
230+
if (/;\s*rel="?next"?/i.test(params)) {
231+
return urlMatch[1];
232+
}
233+
}
234+
235+
return null;
236+
}
237+
238+
export function validatePaginationUrl(
239+
url: string,
240+
allowedOrigin: string
241+
): boolean {
242+
try {
243+
const parsed = new URL(url);
244+
const allowed = new URL(allowedOrigin);
245+
return (
246+
parsed.protocol === allowed.protocol && parsed.host === allowed.host
247+
);
248+
} catch {
249+
return false;
250+
}
224251
}
225252

226253
// Rename archive to add extension because after downloading

0 commit comments

Comments
 (0)