From 6fa22df9daa1c853435e8af4e80cb5b301f1092e Mon Sep 17 00:00:00 2001 From: fuba Date: Mon, 25 May 2026 01:32:22 +0900 Subject: [PATCH 1/2] fix: report real app version from /health and discovery endpoints The /health and GET / (discovery) endpoints hardcoded version '0.1.0', so the API reported a stale version regardless of the actual release. - Add src/utils/version.ts with resolveAppVersion() (APP_VERSION env override -> package.json version -> '0.0.0' fallback) and the shared readPackageVersion() helper - Wire resolveAppVersion() into startApiServer for discovery + health - Reuse readPackageVersion() in docs.ts (drops the duplicated reader) - Mirror the same resolution in the offline E2E harness and assert the resolved version instead of the hardcoded literal - Update doc/API.md example responses to the current version Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/API.md | 6 ++-- src/server/index.ts | 8 ++++-- src/server/routes/docs.ts | 15 ++-------- src/test/offline-api-e2e.test.ts | 3 +- src/test/support/offline-e2e-harness.ts | 7 +++-- src/test/version.test.ts | 37 +++++++++++++++++++++++++ src/utils/version.ts | 26 +++++++++++++++++ 7 files changed, 80 insertions(+), 22 deletions(-) create mode 100644 src/test/version.test.ts create mode 100644 src/utils/version.ts diff --git a/doc/API.md b/doc/API.md index d429b0c..67af488 100644 --- a/doc/API.md +++ b/doc/API.md @@ -14,7 +14,7 @@ Returns the health status of the application, not just HTTP process liveness. ```json { "status": "ok", - "version": "0.1.0", + "version": "3.4.2", "application": "ok" } ``` @@ -23,7 +23,7 @@ Returns the health status of the application, not just HTTP process liveness. ```json { "status": "unhealthy", - "version": "0.1.0", + "version": "3.4.2", "application": "unavailable", "error": "Browser page is closed" } @@ -40,7 +40,7 @@ Returns a service discovery payload with key endpoints and docs hints. "success": true, "data": { "name": "fuba-browser", - "version": "0.1.0", + "version": "3.4.2", "endpoints": { "health": "/health", "api": "/api", diff --git a/src/server/index.ts b/src/server/index.ts index 5eb59aa..b782e86 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -9,6 +9,7 @@ import { SetDeviceProfileFn, GetDeviceProfileFn } from './routes/device.js'; import { TokenStore } from './token-store.js'; import { VncPasswordManager } from './vnc-password-manager.js'; import { discoveryRoutes } from './routes/discovery.js'; +import { resolveAppVersion } from '../utils/version.js'; const DEFAULT_VNC_WEB_PORT = 39001; const DEFAULT_BODY_PARSER_LIMIT = '20mb'; @@ -99,6 +100,7 @@ export async function startApiServer( const app = express(); const vncWebPort = resolveVncWebPort(process.env.VNC_WEB_PORT); const bodyParserLimit = process.env.API_BODY_LIMIT || DEFAULT_BODY_PARSER_LIMIT; + const appVersion = resolveAppVersion(); // Middleware const corsOptions = resolveCorsOptions(process.env.API_CORS_ORIGINS); @@ -108,7 +110,7 @@ export async function startApiServer( app.use(express.json({ limit: bodyParserLimit })); app.use(express.urlencoded({ extended: true, limit: bodyParserLimit })); - app.use(discoveryRoutes('0.1.0')); + app.use(discoveryRoutes(appVersion)); // Health check app.get('/health', async (_req: Request, res: Response) => { @@ -116,7 +118,7 @@ export async function startApiServer( if (!health.ok) { return res.status(503).json({ status: 'unhealthy', - version: '0.1.0', + version: appVersion, application: 'unavailable', error: health.error || 'Application health check failed', }); @@ -124,7 +126,7 @@ export async function startApiServer( return res.json({ status: 'ok', - version: '0.1.0', + version: appVersion, application: 'ok', }); }); diff --git a/src/server/routes/docs.ts b/src/server/routes/docs.ts index 7c435a6..ea8faaf 100644 --- a/src/server/routes/docs.ts +++ b/src/server/routes/docs.ts @@ -1,5 +1,5 @@ import { Request, Response, Router } from 'express'; -import { readFileSync } from 'node:fs'; +import { readPackageVersion } from '../../utils/version.js'; const DEFAULT_DOCS_BASE_REPO_URL = 'https://raw.githubusercontent.com/fuba/fuba-browser'; const DEFAULT_DOCS_REF = 'main'; @@ -46,17 +46,6 @@ function normalizeBaseUrl(baseUrl: string): string { return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; } -function readRuntimePackageVersion(): string | null { - try { - const packageJson = JSON.parse( - readFileSync(new URL('../../../package.json', import.meta.url), 'utf-8') - ) as { version?: string }; - return packageJson.version?.trim() || null; - } catch { - return null; - } -} - function normalizeVersionRef(version: string): string { return version.startsWith('v') ? version : `v${version}`; } @@ -72,7 +61,7 @@ function resolveDefaultDocsRef(): string { return normalizeVersionRef(appVersion); } - const packageVersion = readRuntimePackageVersion(); + const packageVersion = readPackageVersion(); if (packageVersion) { return normalizeVersionRef(packageVersion); } diff --git a/src/test/offline-api-e2e.test.ts b/src/test/offline-api-e2e.test.ts index 65929ea..6123753 100644 --- a/src/test/offline-api-e2e.test.ts +++ b/src/test/offline-api-e2e.test.ts @@ -3,6 +3,7 @@ import { Response } from 'superagent'; import { Snapshot } from '../types/snapshot.js'; import { addConsoleMessage, addPageError } from '../server/routes/debug.js'; import { createOfflineE2EHarness, OfflineE2EHarness } from './support/offline-e2e-harness.js'; +import { resolveAppVersion } from '../utils/version.js'; function binaryResponseParser( res: Response, @@ -64,7 +65,7 @@ describe.sequential('Offline API E2E', () => { const currentHarness = requireHarness(); const healthRes = await currentHarness.agent.get('/health'); expect(healthRes.status).toBe(200); - expect(healthRes.body).toEqual({ status: 'ok', version: '0.1.0', application: 'ok' }); + expect(healthRes.body).toEqual({ status: 'ok', version: resolveAppVersion(), application: 'ok' }); const contentRes = await apiGet('/content'); expect(contentRes.status).toBe(200); diff --git a/src/test/support/offline-e2e-harness.ts b/src/test/support/offline-e2e-harness.ts index 54cd056..faed983 100644 --- a/src/test/support/offline-e2e-harness.ts +++ b/src/test/support/offline-e2e-harness.ts @@ -12,6 +12,7 @@ import { TokenStore } from '../../server/token-store.js'; import { VncPasswordManager } from '../../server/vnc-password-manager.js'; import { buildWebVncRedirectUrl } from '../../server/index.js'; import { errorHandler } from '../../server/middleware/error.js'; +import { resolveAppVersion } from '../../utils/version.js'; interface FixtureServer { baseUrl: string; @@ -239,12 +240,14 @@ export async function createOfflineE2EHarness(): Promise { app.use(express.json({ limit: BODY_PARSER_LIMIT })); app.use(express.urlencoded({ extended: true, limit: BODY_PARSER_LIMIT })); + const appVersion = resolveAppVersion(); + app.get('/health', async (_req, res) => { const health = await browserController.checkHealth(); if (!health.ok) { return res.status(503).json({ status: 'unhealthy', - version: '0.1.0', + version: appVersion, application: 'unavailable', error: health.error || 'Application health check failed', }); @@ -252,7 +255,7 @@ export async function createOfflineE2EHarness(): Promise { return res.json({ status: 'ok', - version: '0.1.0', + version: appVersion, application: 'ok', }); }); diff --git a/src/test/version.test.ts b/src/test/version.test.ts new file mode 100644 index 0000000..9c09ff7 --- /dev/null +++ b/src/test/version.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { readPackageVersion, resolveAppVersion } from '../utils/version.js'; + +const actualVersion = ( + JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf-8') + ) as { version: string } +).version; + +describe('resolveAppVersion', () => { + const originalAppVersion = process.env.APP_VERSION; + + afterEach(() => { + if (originalAppVersion === undefined) { + delete process.env.APP_VERSION; + } else { + process.env.APP_VERSION = originalAppVersion; + } + }); + + it('reads the version from package.json', () => { + delete process.env.APP_VERSION; + expect(readPackageVersion()).toBe(actualVersion); + expect(resolveAppVersion()).toBe(actualVersion); + }); + + it('does not return the stale hardcoded 0.1.0', () => { + delete process.env.APP_VERSION; + expect(resolveAppVersion()).not.toBe('0.1.0'); + }); + + it('honors the APP_VERSION environment override', () => { + process.env.APP_VERSION = '9.9.9'; + expect(resolveAppVersion()).toBe('9.9.9'); + }); +}); diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 0000000..d3dfd8a --- /dev/null +++ b/src/utils/version.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; + +const FALLBACK_VERSION = '0.0.0'; + +// Read the application version from the bundled package.json. +// Returns null when the file cannot be read or has no version field. +export function readPackageVersion(): string | null { + try { + const packageJson = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf-8') + ) as { version?: string }; + return packageJson.version?.trim() || null; + } catch { + return null; + } +} + +// Resolve the application version reported by the API. +// Order: APP_VERSION env override -> package.json version -> fallback. +export function resolveAppVersion(): string { + const override = process.env.APP_VERSION?.trim(); + if (override) { + return override; + } + return readPackageVersion() ?? FALLBACK_VERSION; +} From 056ec43369b8db34fd7946d4781c4a06a6d87894 Mon Sep 17 00:00:00 2001 From: fuba Date: Mon, 25 May 2026 01:35:26 +0900 Subject: [PATCH 2/2] Harden version resolution per codex review - Sanitize version candidates (APP_VERSION env + package.json): reject empty, over-long (>64), or control-character-bearing values so the version cannot smuggle CR/LF/NUL into logs or headers if reused - Validate package.json version type before use (unknown -> normalized) - Add tests for blank, control-char, and over-long APP_VERSION inputs Co-Authored-By: Claude Opus 4.7 (1M context) --- src/test/version.test.ts | 15 +++++++++++++++ src/utils/version.ts | 40 ++++++++++++++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/test/version.test.ts b/src/test/version.test.ts index 9c09ff7..0f75634 100644 --- a/src/test/version.test.ts +++ b/src/test/version.test.ts @@ -34,4 +34,19 @@ describe('resolveAppVersion', () => { process.env.APP_VERSION = '9.9.9'; expect(resolveAppVersion()).toBe('9.9.9'); }); + + it('ignores a blank APP_VERSION and falls back to package.json', () => { + process.env.APP_VERSION = ' '; + expect(resolveAppVersion()).toBe(actualVersion); + }); + + it('ignores an APP_VERSION containing control characters', () => { + process.env.APP_VERSION = 'evil\r\nInjected: header'; + expect(resolveAppVersion()).toBe(actualVersion); + }); + + it('ignores an over-long APP_VERSION', () => { + process.env.APP_VERSION = '1'.repeat(65); + expect(resolveAppVersion()).toBe(actualVersion); + }); }); diff --git a/src/utils/version.ts b/src/utils/version.ts index d3dfd8a..4b5ced0 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -1,15 +1,43 @@ import { readFileSync } from 'node:fs'; const FALLBACK_VERSION = '0.0.0'; +const MAX_VERSION_LENGTH = 64; + +// True when the string contains any C0 control character or DEL. Such values +// are rejected so the version cannot smuggle CR/LF/NUL into logs or headers +// if it is ever reused outside a JSON body. +function hasControlChars(value: string): boolean { + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code <= 0x1f || code === 0x7f) { + return true; + } + } + return false; +} + +// Normalize a version candidate before it is echoed into API responses: +// trim, then reject empty, over-long, or control-character-bearing values. +// Returns null when the value is unusable. +function normalizeVersion(input: unknown): string | null { + if (typeof input !== 'string') { + return null; + } + const value = input.trim(); + if (!value || value.length > MAX_VERSION_LENGTH || hasControlChars(value)) { + return null; + } + return value; +} // Read the application version from the bundled package.json. -// Returns null when the file cannot be read or has no version field. +// Returns null when the file cannot be read or the version is unusable. export function readPackageVersion(): string | null { try { const packageJson = JSON.parse( readFileSync(new URL('../../package.json', import.meta.url), 'utf-8') - ) as { version?: string }; - return packageJson.version?.trim() || null; + ) as { version?: unknown }; + return normalizeVersion(packageJson.version); } catch { return null; } @@ -18,9 +46,5 @@ export function readPackageVersion(): string | null { // Resolve the application version reported by the API. // Order: APP_VERSION env override -> package.json version -> fallback. export function resolveAppVersion(): string { - const override = process.env.APP_VERSION?.trim(); - if (override) { - return override; - } - return readPackageVersion() ?? FALLBACK_VERSION; + return normalizeVersion(process.env.APP_VERSION) ?? readPackageVersion() ?? FALLBACK_VERSION; }