mirror of
https://github.com/actions/setup-java.git
synced 2026-06-15 12:39:40 +00:00
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>
This commit is contained in:
@@ -14,6 +14,7 @@ import * as core from '@actions/core';
|
||||
describe('getAvailableVersions', () => {
|
||||
let spyHttpClient: jest.SpyInstance;
|
||||
let spyCoreError: jest.SpyInstance;
|
||||
let spyCoreWarning: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
|
||||
@@ -26,6 +27,8 @@ describe('getAvailableVersions', () => {
|
||||
// Mock core.error to suppress error logs
|
||||
spyCoreError = jest.spyOn(core, 'error');
|
||||
spyCoreError.mockImplementation(() => {});
|
||||
spyCoreWarning = jest.spyOn(core, 'warning');
|
||||
spyCoreWarning.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -136,22 +139,19 @@ describe('getAvailableVersions', () => {
|
||||
);
|
||||
|
||||
it('load available versions', async () => {
|
||||
const nextPageUrl =
|
||||
'https://api.adoptopenjdk.net/v3/assets/version/%5B1.0,100.0%5D?page=1&page_size=20';
|
||||
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
|
||||
spyHttpClient
|
||||
.mockReturnValueOnce({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
headers: {link: `<${nextPageUrl}>; rel="next"`},
|
||||
result: manifestData as any
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
result: manifestData as any
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
result: []
|
||||
});
|
||||
|
||||
const distribution = new AdoptDistribution(
|
||||
@@ -166,6 +166,34 @@ describe('getAvailableVersions', () => {
|
||||
const availableVersions = await distribution['getAvailableVersions']();
|
||||
expect(availableVersions).not.toBeNull();
|
||||
expect(availableVersions.length).toBe(manifestData.length * 2);
|
||||
expect(spyHttpClient).toHaveBeenNthCalledWith(2, nextPageUrl);
|
||||
});
|
||||
|
||||
it('stops pagination after 1000 pages as a safeguard', async () => {
|
||||
const nextPageUrl =
|
||||
'https://api.adoptopenjdk.net/v3/assets/version/%5B1.0,100.0%5D?page=2&page_size=20';
|
||||
spyHttpClient.mockReturnValue({
|
||||
statusCode: 200,
|
||||
headers: {link: `<${nextPageUrl}>; rel="next"`},
|
||||
result: [{version_data: {semver: '17.0.1'}, binaries: []}] as any
|
||||
});
|
||||
|
||||
const distribution = new AdoptDistribution(
|
||||
{
|
||||
version: '11',
|
||||
architecture: 'x64',
|
||||
packageType: 'jdk',
|
||||
checkLatest: false
|
||||
},
|
||||
AdoptImplementation.Hotspot
|
||||
);
|
||||
|
||||
await distribution['getAvailableVersions']();
|
||||
|
||||
expect(spyHttpClient).toHaveBeenCalledTimes(1000);
|
||||
expect(spyCoreWarning).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Reached pagination safeguard limit (1000 pages)')
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as core from '@actions/core';
|
||||
describe('getAvailableVersions', () => {
|
||||
let spyHttpClient: jest.SpyInstance;
|
||||
let spyCoreError: jest.SpyInstance;
|
||||
let spyCoreWarning: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
|
||||
@@ -20,6 +21,8 @@ describe('getAvailableVersions', () => {
|
||||
// Mock core.error to suppress error logs
|
||||
spyCoreError = jest.spyOn(core, 'error');
|
||||
spyCoreError.mockImplementation(() => {});
|
||||
spyCoreWarning = jest.spyOn(core, 'warning');
|
||||
spyCoreWarning.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -82,22 +85,19 @@ describe('getAvailableVersions', () => {
|
||||
);
|
||||
|
||||
it('load available versions', async () => {
|
||||
const nextPageUrl =
|
||||
'https://api.adoptopenjdk.net/v3/assets/version/%5B1.0,100.0%5D?page=1&page_size=20';
|
||||
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
|
||||
spyHttpClient
|
||||
.mockReturnValueOnce({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
headers: {link: `<${nextPageUrl}>; rel="next"`},
|
||||
result: manifestData as any
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
result: manifestData as any
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
result: []
|
||||
});
|
||||
|
||||
const distribution = new SemeruDistribution({
|
||||
@@ -109,6 +109,31 @@ describe('getAvailableVersions', () => {
|
||||
const availableVersions = await distribution['getAvailableVersions']();
|
||||
expect(availableVersions).not.toBeNull();
|
||||
expect(availableVersions.length).toBe(manifestData.length * 2);
|
||||
expect(spyHttpClient).toHaveBeenNthCalledWith(2, nextPageUrl);
|
||||
});
|
||||
|
||||
it('stops pagination after 1000 pages as a safeguard', async () => {
|
||||
const nextPageUrl =
|
||||
'https://api.adoptopenjdk.net/v3/assets/version/%5B1.0,100.0%5D?page=2&page_size=20';
|
||||
spyHttpClient.mockReturnValue({
|
||||
statusCode: 200,
|
||||
headers: {link: `<${nextPageUrl}>; rel="next"`},
|
||||
result: [{version_data: {semver: '17.0.1'}, binaries: []}] as any
|
||||
});
|
||||
|
||||
const distribution = new SemeruDistribution({
|
||||
version: '8',
|
||||
architecture: 'x64',
|
||||
packageType: 'jdk',
|
||||
checkLatest: false
|
||||
});
|
||||
|
||||
await distribution['getAvailableVersions']();
|
||||
|
||||
expect(spyHttpClient).toHaveBeenCalledTimes(1000);
|
||||
expect(spyCoreWarning).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Reached pagination safeguard limit (1000 pages)')
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as core from '@actions/core';
|
||||
describe('getAvailableVersions', () => {
|
||||
let spyHttpClient: jest.SpyInstance;
|
||||
let spyCoreError: jest.SpyInstance;
|
||||
let spyCoreWarning: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
|
||||
@@ -23,6 +24,8 @@ describe('getAvailableVersions', () => {
|
||||
// Mock core.error to suppress error logs
|
||||
spyCoreError = jest.spyOn(core, 'error');
|
||||
spyCoreError.mockImplementation(() => {});
|
||||
spyCoreWarning = jest.spyOn(core, 'warning');
|
||||
spyCoreWarning.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -93,22 +96,19 @@ describe('getAvailableVersions', () => {
|
||||
);
|
||||
|
||||
it('load available versions', async () => {
|
||||
const nextPageUrl =
|
||||
'https://api.adoptium.net/v3/assets/version/%5B1.0,100.0%5D?page=1&page_size=20';
|
||||
spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson');
|
||||
spyHttpClient
|
||||
.mockReturnValueOnce({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
headers: {link: `<${nextPageUrl}>; rel="next"`},
|
||||
result: manifestData as any
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
result: manifestData as any
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
result: []
|
||||
});
|
||||
|
||||
const distribution = new TemurinDistribution(
|
||||
@@ -123,6 +123,34 @@ describe('getAvailableVersions', () => {
|
||||
const availableVersions = await distribution['getAvailableVersions']();
|
||||
expect(availableVersions).not.toBeNull();
|
||||
expect(availableVersions.length).toBe(manifestData.length * 2);
|
||||
expect(spyHttpClient).toHaveBeenNthCalledWith(2, nextPageUrl);
|
||||
});
|
||||
|
||||
it('stops pagination after 1000 pages as a safeguard', async () => {
|
||||
const nextPageUrl =
|
||||
'https://api.adoptium.net/v3/assets/version/%5B1.0,100.0%5D?page=2&page_size=20';
|
||||
spyHttpClient.mockReturnValue({
|
||||
statusCode: 200,
|
||||
headers: {link: `<${nextPageUrl}>; rel="next"`},
|
||||
result: [{version_data: {semver: '17.0.1'}, binaries: []}] as any
|
||||
});
|
||||
|
||||
const distribution = new TemurinDistribution(
|
||||
{
|
||||
version: '8',
|
||||
architecture: 'x64',
|
||||
packageType: 'jdk',
|
||||
checkLatest: false
|
||||
},
|
||||
TemurinImplementation.Hotspot
|
||||
);
|
||||
|
||||
await distribution['getAvailableVersions']();
|
||||
|
||||
expect(spyHttpClient).toHaveBeenCalledTimes(1000);
|
||||
expect(spyCoreWarning).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Reached pagination safeguard limit (1000 pages)')
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
+75
-1
@@ -4,10 +4,12 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
convertVersionToSemver,
|
||||
getNextPageUrlFromLinkHeader,
|
||||
getVersionFromFileContent,
|
||||
isVersionSatisfies,
|
||||
isCacheFeatureAvailable,
|
||||
isGhes
|
||||
isGhes,
|
||||
validatePaginationUrl
|
||||
} from '../src/util';
|
||||
|
||||
jest.mock('@actions/cache');
|
||||
@@ -85,6 +87,78 @@ describe('convertVersionToSemver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextPageUrlFromLinkHeader', () => {
|
||||
it.each([
|
||||
[
|
||||
{
|
||||
link: '<https://api.adoptium.net/v3/info/release_versions?page=1&page_size=10>; rel="next"'
|
||||
},
|
||||
'https://api.adoptium.net/v3/info/release_versions?page=1&page_size=10'
|
||||
],
|
||||
[
|
||||
{
|
||||
Link: '<https://example.com/last?page=5>; rel="last", <https://example.com/next?page=2>; rel="next"'
|
||||
},
|
||||
'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],
|
||||
[{link: '<https://example.com/page?p=2>; rel="nextsomething"'}, null],
|
||||
[undefined, null]
|
||||
])('returns %s -> %s', (headers, expected) => {
|
||||
expect(getNextPageUrlFromLinkHeader(headers)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts URL with explicit default port', () => {
|
||||
expect(
|
||||
validatePaginationUrl(
|
||||
'https://api.adoptium.net:443/v3/assets?page=2',
|
||||
'https://api.adoptium.net'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVersionFromFileContent', () => {
|
||||
describe('.sdkmanrc', () => {
|
||||
it.each([
|
||||
|
||||
Reference in New Issue
Block a user