Skip to content

Commit 43120bc

Browse files
johnoliverCopilotCopilot
authored
Implement pagination with link headers for Adoptium based apis (#1014)
* Use Link headers for Adoptium pagination * Fix nullable pagination URL types and rebuild dist * Add 1000-page safeguard for JetBrains pagination * Adjust plan for pagination safeguard scope * Move pagination safeguard to non-JetBrains installers * Add 1000-page safeguard to Adopt Temurin and Semeru pagination * Fix Prettier formatting in adopt, semeru, and temurin installer files * Fix CI audit failure by updating vulnerable transitive deps * 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> * Address code review feedback on pagination implementation - Tighten rel regex with word boundary to prevent false positives (e.g., rel="nextsomething" no longer matches). - Use parsed.origin comparison in validatePaginationUrl to correctly handle explicit default ports (e.g., :443 for HTTPS). - Fix pagination safeguard tests to use same-origin URLs so they actually exercise the 1000-page limit instead of being rejected by origin validation on the first request. - Add test for rel="nextsomething" not matching. - Add test for explicit default port acceptance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix prettier formatting in util.test.ts * Rebuild dist/ to fix check-dist CI failure --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ad9d6a6 commit 43120bc

11 files changed

Lines changed: 495 additions & 110 deletions

File tree

__tests__/distributors/adopt-installer.test.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import * as core from '@actions/core';
1414
describe('getAvailableVersions', () => {
1515
let spyHttpClient: jest.SpyInstance;
1616
let spyCoreError: jest.SpyInstance;
17+
let spyCoreWarning: jest.SpyInstance;
1718

1819
beforeEach(() => {
1920
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
@@ -26,6 +27,8 @@ describe('getAvailableVersions', () => {
2627
// Mock core.error to suppress error logs
2728
spyCoreError = jest.spyOn(core, 'error');
2829
spyCoreError.mockImplementation(() => {});
30+
spyCoreWarning = jest.spyOn(core, 'warning');
31+
spyCoreWarning.mockImplementation(() => {});
2932
});
3033

3134
afterEach(() => {
@@ -136,22 +139,19 @@ describe('getAvailableVersions', () => {
136139
);
137140

138141
it('load available versions', async () => {
142+
const nextPageUrl =
143+
'https://api.adoptopenjdk.net/v3/assets/version/%5B1.0,100.0%5D?page=1&page_size=20';
139144
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
140145
spyHttpClient
141146
.mockReturnValueOnce({
142147
statusCode: 200,
143-
headers: {},
148+
headers: {link: `<${nextPageUrl}>; rel="next"`},
144149
result: manifestData as any
145150
})
146151
.mockReturnValueOnce({
147152
statusCode: 200,
148153
headers: {},
149154
result: manifestData as any
150-
})
151-
.mockReturnValueOnce({
152-
statusCode: 200,
153-
headers: {},
154-
result: []
155155
});
156156

157157
const distribution = new AdoptDistribution(
@@ -166,6 +166,34 @@ describe('getAvailableVersions', () => {
166166
const availableVersions = await distribution['getAvailableVersions']();
167167
expect(availableVersions).not.toBeNull();
168168
expect(availableVersions.length).toBe(manifestData.length * 2);
169+
expect(spyHttpClient).toHaveBeenNthCalledWith(2, nextPageUrl);
170+
});
171+
172+
it('stops pagination after 1000 pages as a safeguard', async () => {
173+
const nextPageUrl =
174+
'https://api.adoptopenjdk.net/v3/assets/version/%5B1.0,100.0%5D?page=2&page_size=20';
175+
spyHttpClient.mockReturnValue({
176+
statusCode: 200,
177+
headers: {link: `<${nextPageUrl}>; rel="next"`},
178+
result: [{version_data: {semver: '17.0.1'}, binaries: []}] as any
179+
});
180+
181+
const distribution = new AdoptDistribution(
182+
{
183+
version: '11',
184+
architecture: 'x64',
185+
packageType: 'jdk',
186+
checkLatest: false
187+
},
188+
AdoptImplementation.Hotspot
189+
);
190+
191+
await distribution['getAvailableVersions']();
192+
193+
expect(spyHttpClient).toHaveBeenCalledTimes(1000);
194+
expect(spyCoreWarning).toHaveBeenCalledWith(
195+
expect.stringContaining('Reached pagination safeguard limit (1000 pages)')
196+
);
169197
});
170198

171199
it.each([

__tests__/distributors/semeru-installer.test.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as core from '@actions/core';
99
describe('getAvailableVersions', () => {
1010
let spyHttpClient: jest.SpyInstance;
1111
let spyCoreError: jest.SpyInstance;
12+
let spyCoreWarning: jest.SpyInstance;
1213

1314
beforeEach(() => {
1415
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
@@ -20,6 +21,8 @@ describe('getAvailableVersions', () => {
2021
// Mock core.error to suppress error logs
2122
spyCoreError = jest.spyOn(core, 'error');
2223
spyCoreError.mockImplementation(() => {});
24+
spyCoreWarning = jest.spyOn(core, 'warning');
25+
spyCoreWarning.mockImplementation(() => {});
2326
});
2427

2528
afterEach(() => {
@@ -82,22 +85,19 @@ describe('getAvailableVersions', () => {
8285
);
8386

8487
it('load available versions', async () => {
88+
const nextPageUrl =
89+
'https://api.adoptopenjdk.net/v3/assets/version/%5B1.0,100.0%5D?page=1&page_size=20';
8590
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
8691
spyHttpClient
8792
.mockReturnValueOnce({
8893
statusCode: 200,
89-
headers: {},
94+
headers: {link: `<${nextPageUrl}>; rel="next"`},
9095
result: manifestData as any
9196
})
9297
.mockReturnValueOnce({
9398
statusCode: 200,
9499
headers: {},
95100
result: manifestData as any
96-
})
97-
.mockReturnValueOnce({
98-
statusCode: 200,
99-
headers: {},
100-
result: []
101101
});
102102

103103
const distribution = new SemeruDistribution({
@@ -109,6 +109,31 @@ describe('getAvailableVersions', () => {
109109
const availableVersions = await distribution['getAvailableVersions']();
110110
expect(availableVersions).not.toBeNull();
111111
expect(availableVersions.length).toBe(manifestData.length * 2);
112+
expect(spyHttpClient).toHaveBeenNthCalledWith(2, nextPageUrl);
113+
});
114+
115+
it('stops pagination after 1000 pages as a safeguard', async () => {
116+
const nextPageUrl =
117+
'https://api.adoptopenjdk.net/v3/assets/version/%5B1.0,100.0%5D?page=2&page_size=20';
118+
spyHttpClient.mockReturnValue({
119+
statusCode: 200,
120+
headers: {link: `<${nextPageUrl}>; rel="next"`},
121+
result: [{version_data: {semver: '17.0.1'}, binaries: []}] as any
122+
});
123+
124+
const distribution = new SemeruDistribution({
125+
version: '8',
126+
architecture: 'x64',
127+
packageType: 'jdk',
128+
checkLatest: false
129+
});
130+
131+
await distribution['getAvailableVersions']();
132+
133+
expect(spyHttpClient).toHaveBeenCalledTimes(1000);
134+
expect(spyCoreWarning).toHaveBeenCalledWith(
135+
expect.stringContaining('Reached pagination safeguard limit (1000 pages)')
136+
);
112137
});
113138

114139
it.each([

__tests__/distributors/temurin-installer.test.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as core from '@actions/core';
1212
describe('getAvailableVersions', () => {
1313
let spyHttpClient: jest.SpyInstance;
1414
let spyCoreError: jest.SpyInstance;
15+
let spyCoreWarning: jest.SpyInstance;
1516

1617
beforeEach(() => {
1718
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
@@ -23,6 +24,8 @@ describe('getAvailableVersions', () => {
2324
// Mock core.error to suppress error logs
2425
spyCoreError = jest.spyOn(core, 'error');
2526
spyCoreError.mockImplementation(() => {});
27+
spyCoreWarning = jest.spyOn(core, 'warning');
28+
spyCoreWarning.mockImplementation(() => {});
2629
});
2730

2831
afterEach(() => {
@@ -93,22 +96,19 @@ describe('getAvailableVersions', () => {
9396
);
9497

9598
it('load available versions', async () => {
99+
const nextPageUrl =
100+
'https://api.adoptium.net/v3/assets/version/%5B1.0,100.0%5D?page=1&page_size=20';
96101
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
97102
spyHttpClient
98103
.mockReturnValueOnce({
99104
statusCode: 200,
100-
headers: {},
105+
headers: {link: `<${nextPageUrl}>; rel="next"`},
101106
result: manifestData as any
102107
})
103108
.mockReturnValueOnce({
104109
statusCode: 200,
105110
headers: {},
106111
result: manifestData as any
107-
})
108-
.mockReturnValueOnce({
109-
statusCode: 200,
110-
headers: {},
111-
result: []
112112
});
113113

114114
const distribution = new TemurinDistribution(
@@ -123,6 +123,34 @@ describe('getAvailableVersions', () => {
123123
const availableVersions = await distribution['getAvailableVersions']();
124124
expect(availableVersions).not.toBeNull();
125125
expect(availableVersions.length).toBe(manifestData.length * 2);
126+
expect(spyHttpClient).toHaveBeenNthCalledWith(2, nextPageUrl);
127+
});
128+
129+
it('stops pagination after 1000 pages as a safeguard', async () => {
130+
const nextPageUrl =
131+
'https://api.adoptium.net/v3/assets/version/%5B1.0,100.0%5D?page=2&page_size=20';
132+
spyHttpClient.mockReturnValue({
133+
statusCode: 200,
134+
headers: {link: `<${nextPageUrl}>; rel="next"`},
135+
result: [{version_data: {semver: '17.0.1'}, binaries: []}] as any
136+
});
137+
138+
const distribution = new TemurinDistribution(
139+
{
140+
version: '8',
141+
architecture: 'x64',
142+
packageType: 'jdk',
143+
checkLatest: false
144+
},
145+
TemurinImplementation.Hotspot
146+
);
147+
148+
await distribution['getAvailableVersions']();
149+
150+
expect(spyHttpClient).toHaveBeenCalledTimes(1000);
151+
expect(spyCoreWarning).toHaveBeenCalledWith(
152+
expect.stringContaining('Reached pagination safeguard limit (1000 pages)')
153+
);
126154
});
127155

128156
it.each([

__tests__/util.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import * as fs from 'fs';
44
import * as path from 'path';
55
import {
66
convertVersionToSemver,
7+
getNextPageUrlFromLinkHeader,
78
getVersionFromFileContent,
89
isVersionSatisfies,
910
isCacheFeatureAvailable,
10-
isGhes
11+
isGhes,
12+
validatePaginationUrl
1113
} from '../src/util';
1214

1315
jest.mock('@actions/cache');
@@ -85,6 +87,78 @@ describe('convertVersionToSemver', () => {
8587
});
8688
});
8789

90+
describe('getNextPageUrlFromLinkHeader', () => {
91+
it.each([
92+
[
93+
{
94+
link: '<https://api.adoptium.net/v3/info/release_versions?page=1&page_size=10>; rel="next"'
95+
},
96+
'https://api.adoptium.net/v3/info/release_versions?page=1&page_size=10'
97+
],
98+
[
99+
{
100+
Link: '<https://example.com/last?page=5>; rel="last", <https://example.com/next?page=2>; rel="next"'
101+
},
102+
'https://example.com/next?page=2'
103+
],
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+
],
110+
[{link: '<https://example.com/last?page=5>; rel="last"'}, null],
111+
[{link: '<https://example.com/page?p=2>; rel="nextsomething"'}, null],
112+
[undefined, null]
113+
])('returns %s -> %s', (headers, expected) => {
114+
expect(getNextPageUrlFromLinkHeader(headers)).toBe(expected);
115+
});
116+
});
117+
118+
describe('validatePaginationUrl', () => {
119+
it('accepts URL with matching origin', () => {
120+
expect(
121+
validatePaginationUrl(
122+
'https://api.adoptium.net/v3/assets?page=2',
123+
'https://api.adoptium.net'
124+
)
125+
).toBe(true);
126+
});
127+
128+
it('rejects URL with different host', () => {
129+
expect(
130+
validatePaginationUrl(
131+
'https://evil.example.com/steal?data=1',
132+
'https://api.adoptium.net'
133+
)
134+
).toBe(false);
135+
});
136+
137+
it('rejects URL with different protocol', () => {
138+
expect(
139+
validatePaginationUrl(
140+
'http://api.adoptium.net/v3/assets?page=2',
141+
'https://api.adoptium.net'
142+
)
143+
).toBe(false);
144+
});
145+
146+
it('returns false for invalid URL', () => {
147+
expect(validatePaginationUrl('not-a-url', 'https://api.adoptium.net')).toBe(
148+
false
149+
);
150+
});
151+
152+
it('accepts URL with explicit default port', () => {
153+
expect(
154+
validatePaginationUrl(
155+
'https://api.adoptium.net:443/v3/assets?page=2',
156+
'https://api.adoptium.net'
157+
)
158+
).toBe(true);
159+
});
160+
});
161+
88162
describe('getVersionFromFileContent', () => {
89163
describe('.sdkmanrc', () => {
90164
it.each([

dist/cleanup/index.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52134,7 +52134,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5213452134
return (mod && mod.__esModule) ? mod : { "default": mod };
5213552135
};
5213652136
Object.defineProperty(exports, "__esModule", ({ value: true }));
52137-
exports.renameWinArchive = exports.getGitHubHttpHeaders = exports.convertVersionToSemver = exports.getVersionFromFileContent = exports.isCacheFeatureAvailable = exports.isGhes = exports.isJobStatusSuccess = exports.getToolcachePath = exports.isVersionSatisfies = exports.getDownloadArchiveExtension = exports.extractJdkFile = exports.getVersionFromToolcachePath = exports.getBooleanInput = exports.getTempDir = void 0;
52137+
exports.renameWinArchive = exports.validatePaginationUrl = exports.getNextPageUrlFromLinkHeader = exports.MAX_PAGINATION_PAGES = exports.getGitHubHttpHeaders = exports.convertVersionToSemver = exports.getVersionFromFileContent = exports.isCacheFeatureAvailable = exports.isGhes = exports.isJobStatusSuccess = exports.getToolcachePath = exports.isVersionSatisfies = exports.getDownloadArchiveExtension = exports.extractJdkFile = exports.getVersionFromToolcachePath = exports.getBooleanInput = exports.getTempDir = void 0;
5213852138
const os_1 = __importDefault(__nccwpck_require__(22037));
5213952139
const path_1 = __importDefault(__nccwpck_require__(71017));
5214052140
const fs = __importStar(__nccwpck_require__(57147));
@@ -52301,6 +52301,47 @@ function getGitHubHttpHeaders() {
5230152301
return headers;
5230252302
}
5230352303
exports.getGitHubHttpHeaders = getGitHubHttpHeaders;
52304+
exports.MAX_PAGINATION_PAGES = 1000;
52305+
function getNextPageUrlFromLinkHeader(headers) {
52306+
var _a;
52307+
if (!headers) {
52308+
return null;
52309+
}
52310+
const linkHeader = (_a = headers.link) !== null && _a !== void 0 ? _a : headers.Link;
52311+
if (!linkHeader) {
52312+
return null;
52313+
}
52314+
const normalizedLinkHeader = Array.isArray(linkHeader)
52315+
? linkHeader.join(',')
52316+
: linkHeader;
52317+
// Split into individual link-values and find the one with rel="next"
52318+
// RFC 8288 allows rel to appear anywhere among the parameters
52319+
const linkValues = normalizedLinkHeader.split(/,(?=\s*<)/);
52320+
for (const linkValue of linkValues) {
52321+
const urlMatch = linkValue.match(/<([^>]+)>/);
52322+
if (!urlMatch)
52323+
continue;
52324+
const params = linkValue.slice(urlMatch[0].length);
52325+
// Use word boundary to match "next" as a standalone relation type
52326+
// RFC 8288 allows space-separated relation types like rel="next prev"
52327+
if (/;\s*rel="?[^"]*\bnext\b/i.test(params)) {
52328+
return urlMatch[1];
52329+
}
52330+
}
52331+
return null;
52332+
}
52333+
exports.getNextPageUrlFromLinkHeader = getNextPageUrlFromLinkHeader;
52334+
function validatePaginationUrl(url, allowedOrigin) {
52335+
try {
52336+
const parsed = new URL(url);
52337+
const allowed = new URL(allowedOrigin);
52338+
return parsed.origin === allowed.origin;
52339+
}
52340+
catch (_a) {
52341+
return false;
52342+
}
52343+
}
52344+
exports.validatePaginationUrl = validatePaginationUrl;
5230452345
// Rename archive to add extension because after downloading
5230552346
// archive does not contain extension type and it leads to some issues
5230652347
// on Windows runners without PowerShell Core.

0 commit comments

Comments
 (0)