From ceaa0bfdceb6f809c7a3be57c1edf22174113a51 Mon Sep 17 00:00:00 2001 From: PageZ948 Date: Wed, 19 Aug 2026 21:18:18 +0000 Subject: [PATCH] feat: add safe GitPeji update checks --- src/update-policy.js | 297 +++++++++++++++++++++++++++++++++++++ test/update-policy.test.js | 190 ++++++++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 src/update-policy.js create mode 100644 test/update-policy.test.js diff --git a/src/update-policy.js b/src/update-policy.js new file mode 100644 index 0000000..b01a3a1 --- /dev/null +++ b/src/update-policy.js @@ -0,0 +1,297 @@ +'use strict'; + +const https = require('node:https'); + +const LATEST_RELEASE_URL = 'https://git.pejicorp.com/api/v1/repos/peji/Alta-Proxy-Tool/releases/latest'; +const RELEASES_PAGE_URL = 'https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases'; +const REQUEST_TIMEOUT_MS = 5000; +const MAX_BODY_BYTES = 64 * 1024; +const MAX_RELEASE_NAME_LENGTH = 200; + +// SemVer 2.0.0 without loose forms such as a leading "v" or omitted fields. +const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; + +class UpdatePolicyError extends Error { + constructor(code, message, options) { + super(message, options); + this.name = 'UpdatePolicyError'; + this.code = code; + } +} + +function policyError(code, message, cause) { + return new UpdatePolicyError(code, message, cause ? { cause } : undefined); +} + +function parseSemver(version, errorCode = 'INVALID_VERSION') { + if (typeof version !== 'string') { + throw policyError(errorCode, 'Version must be a strict semantic version string'); + } + + const match = SEMVER_PATTERN.exec(version); + if (!match) { + throw policyError(errorCode, 'Version must use strict SemVer 2.0.0 syntax'); + } + + return { + major: BigInt(match[1]), + minor: BigInt(match[2]), + patch: BigInt(match[3]), + prerelease: match[4] === undefined ? null : match[4].split('.'), + }; +} + +function compareIdentifier(left, right) { + const leftNumeric = /^\d+$/.test(left); + const rightNumeric = /^\d+$/.test(right); + + if (leftNumeric && rightNumeric) { + const leftNumber = BigInt(left); + const rightNumber = BigInt(right); + return leftNumber < rightNumber ? -1 : leftNumber > rightNumber ? 1 : 0; + } + if (leftNumeric !== rightNumeric) { + return leftNumeric ? -1 : 1; + } + return left < right ? -1 : left > right ? 1 : 0; +} + +function compareSemver(leftVersion, rightVersion) { + const left = parseSemver(leftVersion); + const right = parseSemver(rightVersion); + + for (const key of ['major', 'minor', 'patch']) { + if (left[key] < right[key]) return -1; + if (left[key] > right[key]) return 1; + } + + if (left.prerelease === null && right.prerelease === null) return 0; + if (left.prerelease === null) return 1; + if (right.prerelease === null) return -1; + + const count = Math.max(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < count; index += 1) { + if (left.prerelease[index] === undefined) return -1; + if (right.prerelease[index] === undefined) return 1; + const comparison = compareIdentifier(left.prerelease[index], right.prerelease[index]); + if (comparison !== 0) return comparison; + } + return 0; +} + +function sanitizeText(value) { + return value + .trim() + .slice(0, MAX_RELEASE_NAME_LENGTH) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function validateRuntimeValue(value, field) { + if (typeof value !== 'string' || value.length === 0 || value.length > 64 || !/^[a-zA-Z0-9_-]+$/.test(value)) { + throw policyError('INVALID_RUNTIME_METADATA', `Invalid ${field} metadata`); + } + return value; +} + +function parseRelease(body) { + let release; + try { + release = JSON.parse(body.toString('utf8')); + } catch (error) { + throw policyError('INVALID_RESPONSE', 'Release response was not valid JSON', error); + } + + if (!release || typeof release !== 'object' || Array.isArray(release)) { + throw policyError('INVALID_RESPONSE', 'Release response must be an object'); + } + if (typeof release.tag_name !== 'string' || release.tag_name.length > 128) { + throw policyError('INVALID_RESPONSE', 'Release tag_name must be a bounded string'); + } + if (release.name !== undefined && release.name !== null && typeof release.name !== 'string') { + throw policyError('INVALID_RESPONSE', 'Release name must be a string when provided'); + } + if (typeof release.name === 'string' && release.name.length > 1000) { + throw policyError('INVALID_RESPONSE', 'Release name exceeds the schema limit'); + } + if (release.published_at !== undefined && release.published_at !== null && typeof release.published_at !== 'string') { + throw policyError('INVALID_RESPONSE', 'Release published_at must be a string when provided'); + } + + let publishedAt; + if (typeof release.published_at === 'string') { + if (release.published_at.length > 64) { + throw policyError('INVALID_RESPONSE', 'Release published_at exceeds the schema limit'); + } + const timestamp = new Date(release.published_at); + if (Number.isNaN(timestamp.getTime())) { + throw policyError('INVALID_RESPONSE', 'Release published_at must be a valid timestamp'); + } + publishedAt = timestamp.toISOString(); + } + + return { + latestVersion: release.tag_name, + releaseName: sanitizeText(release.name || release.tag_name), + publishedAt, + }; +} + +function defaultRequest({ url, timeoutMs, maxBodyBytes }) { + if (url !== LATEST_RELEASE_URL) { + return Promise.reject(policyError('UNTRUSTED_REQUEST_URL', 'Update checks are restricted to GitPeji')); + } + + return new Promise((resolve, reject) => { + let settled = false; + const finishReject = (error) => { + if (!settled) { + settled = true; + reject(error); + } + }; + + const request = https.get(url, { + headers: { + accept: 'application/json', + 'user-agent': 'Alta-Proxy-Tool-update-check', + }, + agent: false, + }, (response) => { + const chunks = []; + let receivedBytes = 0; + const contentLength = Number(response.headers['content-length']); + + if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) { + response.destroy(); + finishReject(policyError('RESPONSE_TOO_LARGE', 'Release response exceeded the size limit')); + return; + } + + response.on('data', (chunk) => { + receivedBytes += chunk.length; + if (receivedBytes > maxBodyBytes) { + response.destroy(); + finishReject(policyError('RESPONSE_TOO_LARGE', 'Release response exceeded the size limit')); + return; + } + chunks.push(chunk); + }); + response.on('end', () => { + if (settled) return; + settled = true; + resolve({ + statusCode: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks), + url, + }); + }); + response.on('error', finishReject); + }); + + request.setTimeout(timeoutMs, () => { + request.destroy(policyError('REQUEST_TIMEOUT', 'Release check timed out')); + }); + request.on('error', (error) => { + if (error instanceof UpdatePolicyError) { + finishReject(error); + } else { + finishReject(policyError('REQUEST_FAILED', 'Release check failed', error)); + } + }); + }); +} + +async function checkForUpdate({ + currentVersion, + request = defaultRequest, + platform = process.platform, + arch = process.arch, +} = {}) { + parseSemver(currentVersion, 'INVALID_CURRENT_VERSION'); + if (typeof request !== 'function') { + throw policyError('INVALID_REQUEST_ADAPTER', 'Request adapter must be a function'); + } + + const safePlatform = validateRuntimeValue(platform, 'platform'); + const safeArch = validateRuntimeValue(arch, 'architecture'); + const baseMetadata = { + currentVersion, + releasesPageUrl: RELEASES_PAGE_URL, + platform: safePlatform, + arch: safeArch, + }; + + const response = await request({ + url: LATEST_RELEASE_URL, + timeoutMs: REQUEST_TIMEOUT_MS, + maxBodyBytes: MAX_BODY_BYTES, + redirects: 'error', + }); + + if (!response || typeof response !== 'object') { + throw policyError('INVALID_RESPONSE', 'Request adapter returned an invalid response'); + } + if (response.url !== LATEST_RELEASE_URL) { + throw policyError('UNTRUSTED_RESPONSE_URL', 'Release response did not come from the exact GitPeji endpoint'); + } + + const statusCode = Number(response.statusCode); + if (statusCode >= 300 && statusCode < 400) { + throw policyError('REDIRECT_REJECTED', 'Release endpoint redirects are not allowed'); + } + if (statusCode === 404) { + return { status: 'no-release', ...baseMetadata }; + } + if (statusCode !== 200) { + throw policyError('HTTP_ERROR', `Release endpoint returned HTTP ${statusCode}`); + } + + const contentType = response.headers && response.headers['content-type']; + if (typeof contentType !== 'string' || !/^application\/json(?:\s*;|$)/i.test(contentType)) { + throw policyError('INVALID_CONTENT_TYPE', 'Release endpoint did not return JSON'); + } + + const body = Buffer.isBuffer(response.body) + ? response.body + : typeof response.body === 'string' + ? Buffer.from(response.body) + : null; + if (!body) { + throw policyError('INVALID_RESPONSE', 'Release response body must be bytes or text'); + } + if (body.length > MAX_BODY_BYTES) { + throw policyError('RESPONSE_TOO_LARGE', 'Release response exceeded the size limit'); + } + + const release = parseRelease(body); + parseSemver(release.latestVersion, 'INVALID_RELEASE_VERSION'); + + const metadata = { + status: compareSemver(release.latestVersion, currentVersion) > 0 + ? 'update-available' + : 'up-to-date', + ...baseMetadata, + latestVersion: release.latestVersion, + releaseName: release.releaseName, + }; + if (release.publishedAt !== undefined) { + metadata.publishedAt = release.publishedAt; + } + return metadata; +} + +module.exports = { + LATEST_RELEASE_URL, + RELEASES_PAGE_URL, + REQUEST_TIMEOUT_MS, + MAX_BODY_BYTES, + UpdatePolicyError, + checkForUpdate, + compareSemver, + defaultRequest, +}; diff --git a/test/update-policy.test.js b/test/update-policy.test.js new file mode 100644 index 0000000..626ce3f --- /dev/null +++ b/test/update-policy.test.js @@ -0,0 +1,190 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + LATEST_RELEASE_URL, + RELEASES_PAGE_URL, + MAX_BODY_BYTES, + checkForUpdate, + compareSemver, +} = require('../src/update-policy'); + +function response(body, overrides = {}) { + return { + statusCode: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: Buffer.from(body), + url: LATEST_RELEASE_URL, + ...overrides, + }; +} + +async function rejectsWithCode(promise, code) { + await assert.rejects(promise, (error) => { + assert.equal(error.code, code); + return true; + }); +} + +test('valid update returns only sanitized, check-only metadata', async () => { + let requestOptions; + const request = async (options) => { + requestOptions = options; + return response(JSON.stringify({ + id: 42, + tag_name: '1.2.3', + name: ' Security ', + published_at: '2026-08-19T12:34:56Z', + body: 'untrusted release notes', + html_url: 'https://evil.example/download', + assets: [{ browser_download_url: 'https://evil.example/payload.exe' }], + })); + }; + + const result = await checkForUpdate({ + currentVersion: '1.2.2', + request, + platform: 'win32', + arch: 'x64', + }); + + assert.deepEqual(requestOptions, { + url: LATEST_RELEASE_URL, + timeoutMs: 5000, + maxBodyBytes: MAX_BODY_BYTES, + redirects: 'error', + }); + assert.deepEqual(result, { + status: 'update-available', + currentVersion: '1.2.2', + latestVersion: '1.2.3', + releaseName: 'Security <Release>', + publishedAt: '2026-08-19T12:34:56.000Z', + releasesPageUrl: RELEASES_PAGE_URL, + platform: 'win32', + arch: 'x64', + }); + assert.equal(JSON.stringify(result).includes('payload.exe'), false); + assert.equal(JSON.stringify(result).includes('release notes'), false); +}); + +test('equal or older release is reported as up to date', async () => { + const request = async () => response(JSON.stringify({ + tag_name: '2.0.0', + name: 'Current', + published_at: '2026-08-19T12:34:56Z', + })); + + const result = await checkForUpdate({ currentVersion: '2.0.0', request }); + assert.equal(result.status, 'up-to-date'); + assert.equal(result.releasesPageUrl, RELEASES_PAGE_URL); +}); + +test('404 means there is no published release', async () => { + const request = async () => response('', { statusCode: 404 }); + const result = await checkForUpdate({ currentVersion: '1.0.0', request }); + + assert.deepEqual(result, { + status: 'no-release', + currentVersion: '1.0.0', + releasesPageUrl: RELEASES_PAGE_URL, + platform: process.platform, + arch: process.arch, + }); +}); + +test('malformed JSON and invalid response schema fail closed', async () => { + await rejectsWithCode( + checkForUpdate({ currentVersion: '1.0.0', request: async () => response('{') }), + 'INVALID_RESPONSE', + ); + await rejectsWithCode( + checkForUpdate({ currentVersion: '1.0.0', request: async () => response('[]') }), + 'INVALID_RESPONSE', + ); + await rejectsWithCode( + checkForUpdate({ + currentVersion: '1.0.0', + request: async () => response(JSON.stringify({ tag_name: '1.1.0', name: 7 })), + }), + 'INVALID_RESPONSE', + ); +}); + +test('oversized bodies fail before parsing', async () => { + const oversized = Buffer.alloc(MAX_BODY_BYTES + 1, 0x20); + await rejectsWithCode( + checkForUpdate({ + currentVersion: '1.0.0', + request: async () => response('', { body: oversized }), + }), + 'RESPONSE_TOO_LARGE', + ); +}); + +test('redirects and response host drift are rejected', async () => { + await rejectsWithCode( + checkForUpdate({ + currentVersion: '1.0.0', + request: async () => response('', { + statusCode: 302, + headers: { location: 'https://evil.example/latest' }, + }), + }), + 'REDIRECT_REJECTED', + ); + + await rejectsWithCode( + checkForUpdate({ + currentVersion: '1.0.0', + request: async () => response('{}', { url: 'https://evil.example/latest' }), + }), + 'UNTRUSTED_RESPONSE_URL', + ); +}); + +test('invalid or non-strict semver fails closed', async () => { + const invalidVersions = ['v1.2.3', '1.2', '01.2.3', '1.2.3.4', 'latest']; + + for (const tag_name of invalidVersions) { + await rejectsWithCode( + checkForUpdate({ + currentVersion: '1.0.0', + request: async () => response(JSON.stringify({ tag_name })), + }), + 'INVALID_RELEASE_VERSION', + ); + } + + await rejectsWithCode( + checkForUpdate({ currentVersion: 'v1.0.0', request: async () => response('{}') }), + 'INVALID_CURRENT_VERSION', + ); +}); + +test('strict semver comparison handles prerelease precedence', () => { + assert.equal(compareSemver('1.0.0', '1.0.0'), 0); + assert.equal(compareSemver('1.0.1', '1.0.0'), 1); + assert.equal(compareSemver('1.0.0-alpha.2', '1.0.0-alpha.10'), -1); + assert.equal(compareSemver('1.0.0-rc.1', '1.0.0'), -1); + assert.equal(compareSemver('2.0.0+build.1', '2.0.0+build.2'), 0); +}); + +test('unexpected HTTP and content types fail closed', async () => { + await rejectsWithCode( + checkForUpdate({ + currentVersion: '1.0.0', + request: async () => response('server error', { statusCode: 500 }), + }), + 'HTTP_ERROR', + ); + await rejectsWithCode( + checkForUpdate({ + currentVersion: '1.0.0', + request: async () => response('{}', { headers: { 'content-type': 'text/html' } }), + }), + 'INVALID_CONTENT_TYPE', + ); +});