Security
Runeya has undergone 11 security audits covering 90+ points. This document details the principles and patterns applied.
General Principles
- Localhost-only in local-simple mode — the agent binds on
127.0.0.1 - Encrypted secrets at rest (AES-256-GCM) and never sent in plaintext in logs
- Strict validation: all inputs go through Zod schemas before processing
- Fail-closed: decryption error → exception, never returns the ciphertext
- Timing-safe: passphrase comparison via
crypto.timingSafeEqual()
Server → Agent Authentication
typescript
// Agent generates a random passphrase at startup
const passphrase = crypto.randomBytes(32).toString('hex')
// Server stores the encrypted passphrase
agents.json: encrypt(passphrase)
// Each HTTP server → agent request
headers: { 'Authorization': `Bearer ${passphrase}` }
// Agent validates with timing-safe compare
const valid = crypto.timingSafeEqual(
Buffer.from(stored),
Buffer.from(received)
)WebSocket
The WebSocket passphrase is passed in the Authorization header of the upgrade request — never in URL query params.
Input Validation
All tRPC routes use Zod schemas with constraints:
typescript
z.string().min(1).max(128) // IDs, names
z.string().url().max(2048) // URLs (url() + max() both required)
z.string().email().max(320) // Emails
z.array(items).max(100) // Arrays (max() required)
z.record(key, val).refine( // Records (count limited)
r => Object.keys(r).length <= 1000
)Process Isolation
typescript
// NativeRunner — never shell: true by default
spawn(command, args, {
detached: true, // isolated process group
shell: false, // no shell injection
})
// Environment — never ...process.env
// Use buildSafeEnv() with explicit allowlist
const env = buildSafeEnv(resolvedVariables)Secret Encryption
| Element | Method |
|---|---|
Variables with secret: true | AES-256-GCM, key from .encryption.key |
| Agent passphrases | AES-256-GCM, same key |
| Master password | Argon2id + OS keychain pepper |
| AES key | 256 bits, file data/.encryption.key (perms 0o600) |
typescript
// decryptValue() must always throw on failure — never return the ciphertext
function decryptValue(ciphertext: string, key: Buffer): string {
const [ivHex, tagHex, dataHex] = ciphertext.split(':')
if (!ivHex || !tagHex || !dataHex) throw new Error('Invalid ciphertext format')
// validate hex before Buffer.from()
if (!/^[0-9a-f]+$/i.test(ivHex)) throw new Error('Invalid IV hex')
// ...
}Protected Routes
- All tRPC routes use
protectedProcedure publicProcedurereserved for health routes (health.check)- Middleware validates the JWT before calling any resolver
WebSocket Security — Upgrade
typescript
// Validate the passphrase BEFORE handleUpgrade()
wss.on('upgrade', (req, socket, head) => {
const passphrase = extractBearer(req.headers.authorization)
if (!validatePassphrase(passphrase)) {
socket.destroy() // Reject BEFORE handleUpgrade
return
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req)
})
})SSRF Prevention
typescript
// testConnection ALWAYS uses the stored URL, never user input
async function testConnection(agentId: string) {
const agent = await agentStore.get(agentId) // URL from the store
return fetch(agent.url + '/health', { signal: AbortSignal.timeout(5000) })
}AbortSignal.timeout
All fetch calls from server → agent include a timeout:
typescript
fetch(url, {
signal: AbortSignal.timeout(10_000), // 10s max
headers: { Authorization: `Bearer ${passphrase}` },
})Audit Checklist
| Category | Rule |
|---|---|
| Hex validation | Validate /^[0-9a-f]+$/i before Buffer.from(hex) |
| Secrets | Never return a plaintext secret |
| URLs | Never put secrets in query params |
| Fetch | Always use AbortSignal.timeout() |
| Shell | shell: false in NativeRunner |
| Binding | Agent on 127.0.0.1 |
| WS auth | Passphrase in header, before handleUpgrade() |
| Schema | Strings with .max(), arrays with .max() |
| Records | .refine() to limit key count |
| Decryption | Throw on error, never return ciphertext |
| Files | chmod 0o600 on .encryption.key |
| tRPC | protectedProcedure except health |
| SSRF | testConnection uses stored URL |
| Env | buildSafeEnv() — never ...process.env |