diff --git a/__tests__/distributors/adopt-installer.test.ts b/__tests__/distributors/adopt-installer.test.ts index ff477be05..55649255b 100644 --- a/__tests__/distributors/adopt-installer.test.ts +++ b/__tests__/distributors/adopt-installer.test.ts @@ -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([ diff --git a/__tests__/distributors/semeru-installer.test.ts b/__tests__/distributors/semeru-installer.test.ts index 1c26c79a3..03b08b728 100644 --- a/__tests__/distributors/semeru-installer.test.ts +++ b/__tests__/distributors/semeru-installer.test.ts @@ -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([ diff --git a/__tests__/distributors/temurin-installer.test.ts b/__tests__/distributors/temurin-installer.test.ts index 0c6ef3f50..161a2d087 100644 --- a/__tests__/distributors/temurin-installer.test.ts +++ b/__tests__/distributors/temurin-installer.test.ts @@ -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([ diff --git a/__tests__/util.test.ts b/__tests__/util.test.ts index 85b76069e..f41d2c918 100644 --- a/__tests__/util.test.ts +++ b/__tests__/util.test.ts @@ -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: '; rel="next"' + }, + 'https://api.adoptium.net/v3/info/release_versions?page=1&page_size=10' + ], + [ + { + Link: '; rel="last", ; rel="next"' + }, + 'https://example.com/next?page=2' + ], + [ + { + link: '; type="application/json"; rel="next"' + }, + 'https://api.adoptium.net/v3/versions?page=3' + ], + [{link: '; rel="last"'}, null], + [{link: '; 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([ diff --git a/dist/cleanup/index.js b/dist/cleanup/index.js index 9b11cf71e..eff7dac80 100644 --- a/dist/cleanup/index.js +++ b/dist/cleanup/index.js @@ -52134,7 +52134,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -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; +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; const os_1 = __importDefault(__nccwpck_require__(22037)); const path_1 = __importDefault(__nccwpck_require__(71017)); const fs = __importStar(__nccwpck_require__(57147)); @@ -52301,6 +52301,47 @@ function getGitHubHttpHeaders() { return headers; } exports.getGitHubHttpHeaders = getGitHubHttpHeaders; +exports.MAX_PAGINATION_PAGES = 1000; +function getNextPageUrlFromLinkHeader(headers) { + var _a; + if (!headers) { + return null; + } + const linkHeader = (_a = headers.link) !== null && _a !== void 0 ? _a : headers.Link; + if (!linkHeader) { + return null; + } + const normalizedLinkHeader = Array.isArray(linkHeader) + ? linkHeader.join(',') + : linkHeader; + // 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); + // Use word boundary to match "next" as a standalone relation type + // RFC 8288 allows space-separated relation types like rel="next prev" + if (/;\s*rel="?[^"]*\bnext\b/i.test(params)) { + return urlMatch[1]; + } + } + return null; +} +exports.getNextPageUrlFromLinkHeader = getNextPageUrlFromLinkHeader; +function validatePaginationUrl(url, allowedOrigin) { + try { + const parsed = new URL(url); + const allowed = new URL(allowedOrigin); + return parsed.origin === allowed.origin; + } + catch (_a) { + return false; + } +} +exports.validatePaginationUrl = validatePaginationUrl; // Rename archive to add extension because after downloading // archive does not contain extension type and it leads to some issues // on Windows runners without PowerShell Core. diff --git a/dist/setup/index.js b/dist/setup/index.js index eb6cab20b..16fca2a58 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -77896,24 +77896,34 @@ class AdoptDistribution extends base_installer_1.JavaBase { `release_type=${releaseType}`, `jvm_impl=${this.jvmImpl.toLowerCase()}` ].join('&'); - // need to iterate through all pages to retrieve the list of all versions - // Adopt API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 0; + const requestArguments = `${baseRequestArguments}&page_size=20&page=0`; + let availableVersionsUrl = `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; const availableVersions = []; - while (true) { - const requestArguments = `${baseRequestArguments}&page_size=20&page=${page_index}`; - const availableVersionsUrl = `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; - if (core.isDebug() && page_index === 0) { - // url is identical except page_index so print it once for debug - core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + let pageCount = 0; + if (core.isDebug()) { + core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + } + while (availableVersionsUrl) { + pageCount++; + const response = yield this.http.getJson(availableVersionsUrl); + const paginationPage = response.result; + const nextUrl = (0, util_1.getNextPageUrlFromLinkHeader)(response.headers); + if (nextUrl && + !(0, util_1.validatePaginationUrl)(nextUrl, 'https://api.adoptopenjdk.net')) { + core.warning(`Ignoring pagination link with unexpected origin: ${nextUrl}`); + availableVersionsUrl = null; + } + else { + availableVersionsUrl = nextUrl; } - const paginationPage = (yield this.http.getJson(availableVersionsUrl)).result; if (paginationPage === null || paginationPage.length === 0) { - // break infinity loop because we have reached end of pagination break; } availableVersions.push(...paginationPage); - page_index++; + if (pageCount >= util_1.MAX_PAGINATION_PAGES) { + core.warning(`Reached pagination safeguard limit (${util_1.MAX_PAGINATION_PAGES} pages) while listing Adopt releases.`); + break; + } } if (core.isDebug()) { core.startGroup('Print information about available versions'); @@ -80071,24 +80081,34 @@ class SemeruDistribution extends base_installer_1.JavaBase { `release_type=${releaseType}`, `jvm_impl=openj9` ].join('&'); - // need to iterate through all pages to retrieve the list of all versions - // Adoptium API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 0; + const requestArguments = `${baseRequestArguments}&page_size=20&page=0`; + let availableVersionsUrl = `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; const availableVersions = []; - while (true) { - const requestArguments = `${baseRequestArguments}&page_size=20&page=${page_index}`; - const availableVersionsUrl = `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; - if (core.isDebug() && page_index === 0) { - // url is identical except page_index so print it once for debug - core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + let pageCount = 0; + if (core.isDebug()) { + core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + } + while (availableVersionsUrl) { + pageCount++; + const response = yield this.http.getJson(availableVersionsUrl); + const paginationPage = response.result; + const nextUrl = (0, util_1.getNextPageUrlFromLinkHeader)(response.headers); + if (nextUrl && + !(0, util_1.validatePaginationUrl)(nextUrl, 'https://api.adoptopenjdk.net')) { + core.warning(`Ignoring pagination link with unexpected origin: ${nextUrl}`); + availableVersionsUrl = null; + } + else { + availableVersionsUrl = nextUrl; } - const paginationPage = (yield this.http.getJson(availableVersionsUrl)).result; if (paginationPage === null || paginationPage.length === 0) { - // break infinity loop because we have reached end of pagination break; } availableVersions.push(...paginationPage); - page_index++; + if (pageCount >= util_1.MAX_PAGINATION_PAGES) { + core.warning(`Reached pagination safeguard limit (${util_1.MAX_PAGINATION_PAGES} pages) while listing Semeru releases.`); + break; + } } if (core.isDebug()) { core.startGroup('Print information about available versions'); @@ -80245,24 +80265,34 @@ class TemurinDistribution extends base_installer_1.JavaBase { `release_type=${releaseType}`, `jvm_impl=${this.jvmImpl.toLowerCase()}` ].join('&'); - // need to iterate through all pages to retrieve the list of all versions - // Adoptium API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 0; + const requestArguments = `${baseRequestArguments}&page_size=20&page=0`; + let availableVersionsUrl = `https://api.adoptium.net/v3/assets/version/${versionRange}?${requestArguments}`; const availableVersions = []; - while (true) { - const requestArguments = `${baseRequestArguments}&page_size=20&page=${page_index}`; - const availableVersionsUrl = `https://api.adoptium.net/v3/assets/version/${versionRange}?${requestArguments}`; - if (core.isDebug() && page_index === 0) { - // url is identical except page_index so print it once for debug - core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + let pageCount = 0; + if (core.isDebug()) { + core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + } + while (availableVersionsUrl) { + pageCount++; + const response = yield this.http.getJson(availableVersionsUrl); + const paginationPage = response.result; + const nextUrl = (0, util_1.getNextPageUrlFromLinkHeader)(response.headers); + if (nextUrl && + !(0, util_1.validatePaginationUrl)(nextUrl, 'https://api.adoptium.net')) { + core.warning(`Ignoring pagination link with unexpected origin: ${nextUrl}`); + availableVersionsUrl = null; + } + else { + availableVersionsUrl = nextUrl; } - const paginationPage = (yield this.http.getJson(availableVersionsUrl)).result; if (paginationPage === null || paginationPage.length === 0) { - // break infinity loop because we have reached end of pagination break; } availableVersions.push(...paginationPage); - page_index++; + if (pageCount >= util_1.MAX_PAGINATION_PAGES) { + core.warning(`Reached pagination safeguard limit (${util_1.MAX_PAGINATION_PAGES} pages) while listing Temurin releases.`); + break; + } } if (core.isDebug()) { core.startGroup('Print information about available versions'); @@ -80893,7 +80923,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -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; +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; const os_1 = __importDefault(__nccwpck_require__(22037)); const path_1 = __importDefault(__nccwpck_require__(71017)); const fs = __importStar(__nccwpck_require__(57147)); @@ -81060,6 +81090,47 @@ function getGitHubHttpHeaders() { return headers; } exports.getGitHubHttpHeaders = getGitHubHttpHeaders; +exports.MAX_PAGINATION_PAGES = 1000; +function getNextPageUrlFromLinkHeader(headers) { + var _a; + if (!headers) { + return null; + } + const linkHeader = (_a = headers.link) !== null && _a !== void 0 ? _a : headers.Link; + if (!linkHeader) { + return null; + } + const normalizedLinkHeader = Array.isArray(linkHeader) + ? linkHeader.join(',') + : linkHeader; + // 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); + // Use word boundary to match "next" as a standalone relation type + // RFC 8288 allows space-separated relation types like rel="next prev" + if (/;\s*rel="?[^"]*\bnext\b/i.test(params)) { + return urlMatch[1]; + } + } + return null; +} +exports.getNextPageUrlFromLinkHeader = getNextPageUrlFromLinkHeader; +function validatePaginationUrl(url, allowedOrigin) { + try { + const parsed = new URL(url); + const allowed = new URL(allowedOrigin); + return parsed.origin === allowed.origin; + } + catch (_a) { + return false; + } +} +exports.validatePaginationUrl = validatePaginationUrl; // Rename archive to add extension because after downloading // archive does not contain extension type and it leads to some issues // on Windows runners without PowerShell Core. diff --git a/package-lock.json b/package-lock.json index 208a2e9e2..b910a8f01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6470,6 +6470,21 @@ "node": ">=16.0.0" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlbuilder2": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz", diff --git a/src/distributions/adopt/installer.ts b/src/distributions/adopt/installer.ts index 34c1716cd..b6393f726 100644 --- a/src/distributions/adopt/installer.ts +++ b/src/distributions/adopt/installer.ts @@ -14,9 +14,12 @@ import { } from '../base-models'; import { extractJdkFile, + getNextPageUrlFromLinkHeader, getDownloadArchiveExtension, isVersionSatisfies, - renameWinArchive + renameWinArchive, + MAX_PAGINATION_PAGES, + validatePaginationUrl } from '../../util'; export enum AdoptImplementation { @@ -125,30 +128,46 @@ export class AdoptDistribution extends JavaBase { `jvm_impl=${this.jvmImpl.toLowerCase()}` ].join('&'); - // need to iterate through all pages to retrieve the list of all versions - // Adopt API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 0; + const requestArguments = `${baseRequestArguments}&page_size=20&page=0`; + let availableVersionsUrl: string | null = + `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; const availableVersions: IAdoptAvailableVersions[] = []; - while (true) { - const requestArguments = `${baseRequestArguments}&page_size=20&page=${page_index}`; - const availableVersionsUrl = `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; - if (core.isDebug() && page_index === 0) { - // url is identical except page_index so print it once for debug - core.debug( - `Gathering available versions from '${availableVersionsUrl}'` + let pageCount = 0; + if (core.isDebug()) { + core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + } + + while (availableVersionsUrl) { + pageCount++; + const response = + await this.http.getJson( + availableVersionsUrl ); + const paginationPage = response.result; + 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; } - - const paginationPage = ( - await this.http.getJson(availableVersionsUrl) - ).result; if (paginationPage === null || paginationPage.length === 0) { - // break infinity loop because we have reached end of pagination break; } availableVersions.push(...paginationPage); - page_index++; + + if (pageCount >= MAX_PAGINATION_PAGES) { + core.warning( + `Reached pagination safeguard limit (${MAX_PAGINATION_PAGES} pages) while listing Adopt releases.` + ); + break; + } } if (core.isDebug()) { diff --git a/src/distributions/semeru/installer.ts b/src/distributions/semeru/installer.ts index edb294803..a043f16e4 100644 --- a/src/distributions/semeru/installer.ts +++ b/src/distributions/semeru/installer.ts @@ -7,9 +7,12 @@ import { import semver from 'semver'; import { extractJdkFile, + getNextPageUrlFromLinkHeader, getDownloadArchiveExtension, isVersionSatisfies, - renameWinArchive + renameWinArchive, + MAX_PAGINATION_PAGES, + validatePaginationUrl } from '../../util'; import * as core from '@actions/core'; import * as tc from '@actions/tool-cache'; @@ -155,32 +158,46 @@ export class SemeruDistribution extends JavaBase { `jvm_impl=openj9` ].join('&'); - // need to iterate through all pages to retrieve the list of all versions - // Adoptium API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 0; + const requestArguments = `${baseRequestArguments}&page_size=20&page=0`; + let availableVersionsUrl: string | null = + `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; const availableVersions: ISemeruAvailableVersions[] = []; - while (true) { - const requestArguments = `${baseRequestArguments}&page_size=20&page=${page_index}`; - const availableVersionsUrl = `https://api.adoptopenjdk.net/v3/assets/version/${versionRange}?${requestArguments}`; - if (core.isDebug() && page_index === 0) { - // url is identical except page_index so print it once for debug - core.debug( - `Gathering available versions from '${availableVersionsUrl}'` - ); - } + let pageCount = 0; + if (core.isDebug()) { + core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + } - const paginationPage = ( + while (availableVersionsUrl) { + pageCount++; + const response = await this.http.getJson( availableVersionsUrl - ) - ).result; + ); + const paginationPage = response.result; + 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 infinity loop because we have reached end of pagination break; } availableVersions.push(...paginationPage); - page_index++; + + if (pageCount >= MAX_PAGINATION_PAGES) { + core.warning( + `Reached pagination safeguard limit (${MAX_PAGINATION_PAGES} pages) while listing Semeru releases.` + ); + break; + } } if (core.isDebug()) { diff --git a/src/distributions/temurin/installer.ts b/src/distributions/temurin/installer.ts index 51d523f6e..7e5617cb1 100644 --- a/src/distributions/temurin/installer.ts +++ b/src/distributions/temurin/installer.ts @@ -14,9 +14,12 @@ import { } from '../base-models'; import { extractJdkFile, + getNextPageUrlFromLinkHeader, getDownloadArchiveExtension, isVersionSatisfies, - renameWinArchive + renameWinArchive, + MAX_PAGINATION_PAGES, + validatePaginationUrl } from '../../util'; export enum TemurinImplementation { @@ -123,32 +126,47 @@ export class TemurinDistribution extends JavaBase { `jvm_impl=${this.jvmImpl.toLowerCase()}` ].join('&'); - // need to iterate through all pages to retrieve the list of all versions - // Adoptium API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 0; + const requestArguments = `${baseRequestArguments}&page_size=20&page=0`; + let availableVersionsUrl: string | null = + `https://api.adoptium.net/v3/assets/version/${versionRange}?${requestArguments}`; const availableVersions: ITemurinAvailableVersions[] = []; - while (true) { - const requestArguments = `${baseRequestArguments}&page_size=20&page=${page_index}`; - const availableVersionsUrl = `https://api.adoptium.net/v3/assets/version/${versionRange}?${requestArguments}`; - if (core.isDebug() && page_index === 0) { - // url is identical except page_index so print it once for debug - core.debug( - `Gathering available versions from '${availableVersionsUrl}'` - ); - } + let pageCount = 0; + if (core.isDebug()) { + core.debug(`Gathering available versions from '${availableVersionsUrl}'`); + } - const paginationPage = ( + while (availableVersionsUrl) { + pageCount++; + const response = await this.http.getJson( availableVersionsUrl - ) - ).result; + ); + const paginationPage = response.result; + 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 infinity loop because we have reached end of pagination break; } availableVersions.push(...paginationPage); - page_index++; + + if (pageCount >= MAX_PAGINATION_PAGES) { + core.warning( + `Reached pagination safeguard limit (${MAX_PAGINATION_PAGES} pages) while listing Temurin releases.` + ); + break; + } } if (core.isDebug()) { diff --git a/src/util.ts b/src/util.ts index 0325f7f42..5fe84c520 100644 --- a/src/util.ts +++ b/src/util.ts @@ -201,6 +201,55 @@ export function getGitHubHttpHeaders(): OutgoingHttpHeaders { return headers; } +export const MAX_PAGINATION_PAGES = 1000; + +export function getNextPageUrlFromLinkHeader( + headers?: Record +): string | null { + if (!headers) { + return null; + } + + const linkHeader = headers.link ?? headers.Link; + if (!linkHeader) { + return null; + } + + const normalizedLinkHeader = Array.isArray(linkHeader) + ? linkHeader.join(',') + : linkHeader; + + // 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); + // Use word boundary to match "next" as a standalone relation type + // RFC 8288 allows space-separated relation types like rel="next prev" + if (/;\s*rel="?[^"]*\bnext\b/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.origin === allowed.origin; + } catch { + return false; + } +} + // Rename archive to add extension because after downloading // archive does not contain extension type and it leads to some issues // on Windows runners without PowerShell Core.