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..0f75634 --- /dev/null +++ b/src/test/version.test.ts @@ -0,0 +1,52 @@ +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'); + }); + + 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 new file mode 100644 index 0000000..4b5ced0 --- /dev/null +++ b/src/utils/version.ts @@ -0,0 +1,50 @@ +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 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?: unknown }; + return normalizeVersion(packageJson.version); + } catch { + return null; + } +} + +// Resolve the application version reported by the API. +// Order: APP_VERSION env override -> package.json version -> fallback. +export function resolveAppVersion(): string { + return normalizeVersion(process.env.APP_VERSION) ?? readPackageVersion() ?? FALLBACK_VERSION; +}