Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions doc/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
```
Expand All @@ -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"
}
Expand All @@ -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",
Expand Down
8 changes: 5 additions & 3 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -108,23 +110,23 @@ 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) => {
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',
});
}

return res.json({
status: 'ok',
version: '0.1.0',
version: appVersion,
application: 'ok',
});
});
Expand Down
15 changes: 2 additions & 13 deletions src/server/routes/docs.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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}`;
}
Expand All @@ -72,7 +61,7 @@ function resolveDefaultDocsRef(): string {
return normalizeVersionRef(appVersion);
}

const packageVersion = readRuntimePackageVersion();
const packageVersion = readPackageVersion();
if (packageVersion) {
return normalizeVersionRef(packageVersion);
}
Expand Down
3 changes: 2 additions & 1 deletion src/test/offline-api-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions src/test/support/offline-e2e-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -239,20 +240,22 @@ export async function createOfflineE2EHarness(): Promise<OfflineE2EHarness> {
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',
});
}

return res.json({
status: 'ok',
version: '0.1.0',
version: appVersion,
application: 'ok',
});
});
Expand Down
52 changes: 52 additions & 0 deletions src/test/version.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
50 changes: 50 additions & 0 deletions src/utils/version.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading