Skip to content

Commit 90ccc28

Browse files
authored
Merge pull request #32 from ownpilot/security/rce-and-high-fixes
security: remediate Critical RCEs + High/Medium findings (scan 2026-06-01)
2 parents fb43823 + d44a31c commit 90ccc28

36 files changed

Lines changed: 927 additions & 145 deletions

.github/workflows/ci.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,18 @@ jobs:
2828
node-version: [22]
2929

3030
steps:
31-
- uses: actions/checkout@v4
31+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
3232

33-
- uses: pnpm/action-setup@v4
33+
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
3434

3535
- name: Use Node.js ${{ matrix.node-version }}
36-
uses: actions/setup-node@v4
36+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
3737
with:
3838
node-version: ${{ matrix.node-version }}
3939
cache: 'pnpm'
4040

4141
- name: Cache Turbo
42-
uses: actions/cache@v4
42+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
4343
with:
4444
path: .turbo
4545
key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
@@ -102,12 +102,12 @@ jobs:
102102
--health-retries 5
103103
104104
steps:
105-
- uses: actions/checkout@v4
105+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
106106

107-
- uses: pnpm/action-setup@v4
107+
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
108108

109109
- name: Use Node.js 22
110-
uses: actions/setup-node@v4
110+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
111111
with:
112112
node-version: 22
113113
cache: 'pnpm'

.github/workflows/deploy-website.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ jobs:
2525
working-directory: website
2626
steps:
2727
- name: Checkout
28-
uses: actions/checkout@v4
28+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
2929

3030
- name: Setup Node.js
31-
uses: actions/setup-node@v4
31+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
3232
with:
3333
node-version: '22'
3434
cache: 'npm'
@@ -41,10 +41,10 @@ jobs:
4141
run: npm run build
4242

4343
- name: Setup Pages
44-
uses: actions/configure-pages@v5
44+
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
4545

4646
- name: Upload artifact
47-
uses: actions/upload-pages-artifact@v3
47+
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
4848
with:
4949
path: website/dist
5050

@@ -57,4 +57,4 @@ jobs:
5757
steps:
5858
- name: Deploy to GitHub Pages
5959
id: deployment
60-
uses: actions/deploy-pages@v4
60+
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4

.github/workflows/release.yml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,20 @@ jobs:
1919

2020
steps:
2121
- name: Checkout
22-
uses: actions/checkout@v4
22+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
2323
with:
2424
fetch-depth: 0
2525

26-
- uses: pnpm/action-setup@v4
26+
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
2727

2828
- name: Use Node.js 22
29-
uses: actions/setup-node@v4
29+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
3030
with:
3131
node-version: 22
3232
cache: 'pnpm'
3333

3434
- name: Cache Turbo
35-
uses: actions/cache@v4
35+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
3636
with:
3737
path: .turbo
3838
key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
@@ -59,18 +59,18 @@ jobs:
5959
run: TURBO_FORCE=true pnpm test
6060

6161
- name: Set up Docker Buildx
62-
uses: docker/setup-buildx-action@v3
62+
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
6363

6464
- name: Login to GHCR
65-
uses: docker/login-action@v3
65+
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
6666
with:
6767
registry: ghcr.io
6868
username: ${{ github.actor }}
6969
password: ${{ secrets.GITHUB_TOKEN }}
7070

7171
- name: Docker metadata
7272
id: meta
73-
uses: docker/metadata-action@v5
73+
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
7474
with:
7575
images: ghcr.io/ownpilot/ownpilot
7676
tags: |
@@ -79,7 +79,7 @@ jobs:
7979
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
8080
8181
- name: Build and push Docker image
82-
uses: docker/build-push-action@v6
82+
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
8383
with:
8484
context: .
8585
push: true
@@ -90,6 +90,6 @@ jobs:
9090
cache-to: type=gha,mode=max
9191

9292
- name: Create GitHub Release
93-
uses: softprops/action-gh-release@v2
93+
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
9494
with:
9595
generate_release_notes: true

docker-compose.db.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ services:
2222
- '127.0.0.1:${POSTGRES_PORT:-25432}:5432'
2323
environment:
2424
POSTGRES_USER: ${POSTGRES_USER:-ownpilot}
25+
# SECURITY (DOCK-003): `ownpilot_secret` is a committed, publicly-known
26+
# fallback intended ONLY for local development. The port above is bound to
27+
# 127.0.0.1, so the DB is never exposed beyond this host. For any shared,
28+
# remote, or production use you MUST set POSTGRES_PASSWORD in your .env to
29+
# a strong secret (the gateway reads the same value).
2530
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ownpilot_secret}
2631
POSTGRES_DB: ${POSTGRES_DB:-ownpilot}
2732
volumes:

docker-compose.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,13 @@ services:
8686
container_name: ownpilot-mosquitto
8787
restart: unless-stopped
8888
ports:
89-
- '${MQTT_PORT:-1883}:1883'
90-
- '${MQTT_WS_PORT:-9001}:9001'
89+
# SECURITY (DOCK-004/IAC-002): bind to IPv4 loopback only. The broker
90+
# ships with allow_anonymous=true, so exposing these ports on 0.0.0.0
91+
# would give any host on the LAN full read/write on all MQTT topics
92+
# (including edge-device control + agent messaging). Other containers
93+
# still reach the broker over the compose network by service name.
94+
- '127.0.0.1:${MQTT_PORT:-1883}:1883'
95+
- '127.0.0.1:${MQTT_WS_PORT:-9001}:9001'
9196
volumes:
9297
- ./packages/gateway/docker/mosquitto/config:/mosquitto/config:ro
9398
- mosquitto-data:/mosquitto/data

packages/core/src/credentials/index.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,10 @@ function deriveKey(masterKey: string, salt?: Buffer): Buffer {
218218
if (salt) {
219219
return pbkdf2Sync(masterKey, salt, PBKDF2_ITERATIONS, 32, 'sha256');
220220
}
221-
// Legacy fallback — single SHA-256 (no brute-force resistance)
221+
// Legacy fallback — single SHA-256 (no brute-force resistance). Retained
222+
// ONLY to read pre-salt entries; those are re-encrypted with PBKDF2+salt on
223+
// first read via migrateLegacyEntryIfNeeded (CRYPTO-004), so this path is
224+
// self-healing and a no-salt entry should not persist past one read.
222225
return createHash('sha256').update(masterKey).digest();
223226
}
224227

@@ -336,6 +339,25 @@ export class UserCredentialStore {
336339
return id;
337340
}
338341

342+
/**
343+
* CRYPTO-004: lazily upgrade legacy (no-salt, single-SHA-256) entries to the
344+
* PBKDF2+salt scheme the first time they are successfully decrypted, so the
345+
* weak O(1) key derivation never survives a read. Non-fatal on failure — a
346+
* migration error must never break a credential read.
347+
*/
348+
private async migrateLegacyEntryIfNeeded(
349+
entry: CredentialEntry,
350+
plaintext: string
351+
): Promise<void> {
352+
if (entry.salt) return;
353+
try {
354+
const { encrypted, iv, salt } = encryptValue(plaintext, this.config.encryptionKey);
355+
await this.backend.set({ ...entry, encryptedValue: encrypted, iv, salt });
356+
} catch {
357+
// best-effort; keep serving the decrypted value
358+
}
359+
}
360+
339361
/**
340362
* Get a credential for use
341363
*/
@@ -359,6 +381,9 @@ export class UserCredentialStore {
359381
entry.salt
360382
);
361383

384+
// CRYPTO-004: upgrade legacy no-salt entries on read.
385+
await this.migrateLegacyEntryIfNeeded(entry, value);
386+
362387
// Update usage
363388
await this.updateUsage(entry);
364389

@@ -394,6 +419,9 @@ export class UserCredentialStore {
394419
entry.salt
395420
);
396421

422+
// CRYPTO-004: upgrade legacy no-salt entries on read.
423+
await this.migrateLegacyEntryIfNeeded(entry, value);
424+
397425
// Update usage
398426
await this.updateUsage(entry);
399427

packages/core/src/sandbox/code-validator.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,24 @@ describe('new dangerous patterns', () => {
122122
expect(result.valid).toBe(false);
123123
expect(result.errors).toContain('Atomics is not allowed (timing attack vector)');
124124
});
125+
126+
// RCE-001 regression: the constructor-walk escape relies on splitting the
127+
// word "constructor" across string-concat to evade the literal-token check.
128+
// Every single split point must be caught. (Defense-in-depth; the VM sandbox
129+
// itself no longer injects host objects, so even a 3-way split cannot escape.)
130+
it.each([
131+
`Math['constructo'+'r']('return process')`,
132+
`Math['construct'+'or']('x')`,
133+
`Math['con'+'structor']('x')`,
134+
`Math['constr'+'uctor']('x')`,
135+
`Math['constru'+'ctor']('x')`,
136+
`obj['co'+'nstructor']`,
137+
`obj['constructo' + 'r']`,
138+
])('blocks split-string constructor access: %s', (code) => {
139+
const result = validateToolCode(code);
140+
expect(result.valid).toBe(false);
141+
expect(result.errors).toContain('string-concat constructor access is not allowed');
142+
});
125143
});
126144

127145
// ---------------------------------------------------------------------------

packages/core/src/sandbox/code-validator.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,21 @@ export const DANGEROUS_CODE_PATTERNS: ReadonlyArray<CodeValidationPattern> = [
106106
pattern: /[.[]\s*['"]?\s*constructor\b/,
107107
message: 'constructor property access is not allowed',
108108
},
109-
// String-concat bypass — `obj['construct'+'or']` evades the literal-token
109+
// String-concat bypass — `obj['constructo'+'r']` evades the literal-token
110110
// check above. Legitimate code does not assemble the word "constructor"
111-
// from fragments. Catch the common splits explicitly.
111+
// from fragments. These two patterns cover EVERY single split point of the
112+
// word (left prefix immediately followed by `+`, or `+` immediately followed
113+
// by a right suffix). NOTE: this is defense-in-depth only — the real fix is
114+
// that the VM sandbox no longer injects any host-realm object, so even a
115+
// multi-fragment split that evades these regexes can only reach the
116+
// context-realm Function constructor, which `codeGeneration.strings:false`
117+
// disables. See services/extension/sandbox.ts (RCE-001).
112118
{
113-
pattern: /(['"]construct['"]|['"]constr['"]|['"]con['"]).*\+/,
119+
pattern: /['"](?:co|con|cons|const|constr|constru|construc|construct|constructo)['"]\s*\+/,
114120
message: 'string-concat constructor access is not allowed',
115121
},
116122
{
117-
pattern: /\+.*?(['"]uctor['"]|['"]ructor['"]|['"]tor['"]|['"]structor['"])/,
123+
pattern: /\+\s*['"](?:r|or|tor|ctor|uctor|ructor|tructor|structor|nstructor|onstructor)['"]/,
118124
message: 'string-concat constructor access is not allowed',
119125
},
120126
{ pattern: /\bgetPrototypeOf\b/, message: 'getPrototypeOf is not allowed' },

packages/gateway/src/channels/service-impl.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* through EventBus, handles verification, and manages sessions.
77
*/
88

9-
import { timingSafeEqual } from 'node:crypto';
9+
import { timingSafeEqual, randomInt } from 'node:crypto';
1010
import {
1111
type IChannelService,
1212
type ChannelOutgoingMessage,
@@ -1430,8 +1430,10 @@ export class ChannelServiceImpl implements IChannelService {
14301430
senderUserId: string,
14311431
_ownerUserId: string
14321432
): Promise<string> {
1433-
// Generate 6-digit code
1434-
const code = String(Math.floor(100000 + Math.random() * 900000));
1433+
// Generate 6-digit code. SECURITY (EXPOSE-004): use a CSPRNG — Math.random()
1434+
// is a non-cryptographic PRNG whose internal state can be recovered from a
1435+
// few observed outputs, making pairing codes predictable/brute-forceable.
1436+
const code = String(randomInt(100000, 1000000));
14351437

14361438
// Store in verification_tokens
14371439
await this.dmPairingRequests.create({

packages/gateway/src/db/repositories/logs.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,18 @@ describe('LogsRepository', () => {
178178
expect(params[18]).toBeNull(); // userAgent
179179
});
180180

181+
it('truncates an oversized error stack to the cap (EXPOSE-001)', async () => {
182+
mockAdapter.execute.mockResolvedValueOnce({ changes: 1 });
183+
mockAdapter.queryOne.mockResolvedValueOnce(makeLogRow());
184+
185+
const hugeStack = 'Error: boom\n' + ' at frame\n'.repeat(5000);
186+
await repo.log({ type: 'chat', errorStack: hugeStack });
187+
188+
const params = mockAdapter.execute.mock.calls[0]![1] as unknown[];
189+
expect((params[16] as string).length).toBe(2000); // persisted stack capped
190+
expect(params[16]).toBe(hugeStack.slice(0, 2000));
191+
});
192+
181193
it('should return a fallback log entry when insert fails', async () => {
182194
mockAdapter.execute.mockRejectedValueOnce(new Error('DB connection lost'));
183195

0 commit comments

Comments
 (0)