Skip to content

Commit 1e8b680

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(security): close four P0 launch-readiness findings (#1586)
P0-1 plugin-auth: generateSecret() throws (fails boot) when OS_AUTH_SECRET is unset and NODE_ENV==='production' instead of using a predictable dev-secret-<ts> (session forgery). Dev/test fallback unchanged. P0-2 plugin-security: permission-resolution catch now FAILS CLOSED — logs ERROR and throws PermissionDeniedError rather than return next(), so a degraded metadata service can't bypass RBAC/RLS. System ops still bypass. P0-3 driver-sql: contains/$contains escapes LIKE metacharacters (% _ \) and binds explicit ESCAPE '\' (SQLite needs it) so '%' matches literally, not every row (filter bypass). P0-4 driver-mongodb: field-operator translator rejects unknown $-operators instead of passing them through, blocking $where/$function/$expr (server-side JS execution / query-intent bypass). Each finding was hand-verified against main first. +12 regression tests across the four packages. docs/launch-readiness.md updated: P0-1..4 marked Verify ✅ (fixed), Sign-off left for the team. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 26e9941 commit 1e8b680

10 files changed

Lines changed: 228 additions & 30 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/plugin-security': patch
4+
'@objectstack/driver-sql': patch
5+
'@objectstack/driver-mongodb': patch
6+
---
7+
8+
fix(security): close four P0 launch-readiness findings
9+
10+
- **plugin-auth (P0-1):** `generateSecret()` now throws (fails boot) when no
11+
`OS_AUTH_SECRET` is set and `NODE_ENV==='production'`, instead of silently
12+
falling back to a predictable `dev-secret-<timestamp>` (session forgery). The
13+
dev/test fallback is unchanged.
14+
- **plugin-security (P0-2):** the permission-resolution `catch` now **fails
15+
closed** — it logs at ERROR and throws `PermissionDeniedError` rather than
16+
`return next()`. A degraded metadata service can no longer let every
17+
authenticated request bypass RBAC/RLS. System operations still bypass as before.
18+
- **driver-sql (P0-3):** the `contains` / `$contains` operator now escapes LIKE
19+
metacharacters (`%` / `_` / `\`) in the user value and binds an explicit
20+
`ESCAPE '\'`, so a value of `%` matches literally instead of every row
21+
(filter bypass). Correct across SQLite/MySQL/Postgres.
22+
- **driver-mongodb (P0-4):** the field-operator translator now rejects unknown
23+
`$`-operators instead of passing them through, blocking `$where` / `$function`
24+
/ `$expr` (server-side JS execution / query-intent bypass). All legitimate
25+
ObjectQL operators remain allowlisted.
26+
27+
+12 regression tests across the four packages.

docs/launch-readiness.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ fix or acceptance.**
5555
- **Action:** Throw (fail boot) when no secret is configured and
5656
`NODE_ENV === 'production'`; add a pre-boot config validation. Document
5757
`OS_AUTH_SECRET` as a required go-live env var.
58-
- **Owner:** _______ · Verify · Sign-off ☐ · Notes: _______
58+
- **Owner:** _______ · Verify ✅ (confirmed real @ `main`) · Sign-off ☐ · Notes: Fixed — `generateSecret()` throws in production; +3 tests. Awaiting human sign-off.
5959

6060
### P0-2 — Metadata-service failure bypasses all RBAC/RLS (fail-open)
6161
- **Area:** `plugin-security``src/security-plugin.ts:309–312`
@@ -65,7 +65,7 @@ fix or acceptance.**
6565
- **Action:** Add a circuit-breaker + ERROR-level alerting; add an integration
6666
test asserting "metadata service down ⇒ request denied", not allowed. Decide
6767
fail-closed vs. fail-open explicitly and record the decision.
68-
- **Owner:** _______ · Verify · Sign-off ☐ · Notes: _______
68+
- **Owner:** _______ · Verify ✅ (confirmed real @ `main`) · Sign-off ☐ · Notes: Decision = **fail-closed**. `catch` now logs ERROR + throws `PermissionDeniedError`; system ops still bypass. +2 tests. Awaiting human sign-off.
6969

7070
### P0-3 — Unescaped LIKE metacharacters in `contains`
7171
- **Area:** `driver-sql``src/sql-driver.ts:1565, 1656`
@@ -75,7 +75,7 @@ fix or acceptance.**
7575
(HIGH, data)
7676
- **Action:** Escape `%` and `_` (and the escape char) before building the LIKE
7777
pattern; add a test with a `%`/`_` payload.
78-
- **Owner:** _______ · Verify · Sign-off ☐ · Notes: _______
78+
- **Owner:** _______ · Verify ✅ (confirmed real @ `main`) · Sign-off ☐ · Notes: Fixed — escape `%`/`_`/`\` + explicit `ESCAPE '\'` (SQLite needs it); +3 tests. Awaiting human sign-off.
7979

8080
### P0-4 — MongoDB filter passes arbitrary `$` operators through
8181
- **Area:** `driver-mongodb``src/mongodb-filter.ts:82–84`
@@ -85,7 +85,7 @@ fix or acceptance.**
8585
- **Action:** Allowlist safe operators (`$eq/$ne/$gt/$gte/$lt/$lte/$in/$nin/$and/$or/...`);
8686
reject unknown ones at filter-build time. If MongoDB is not a v1 driver, mark
8787
Roadmap instead.
88-
- **Owner:** _______ · Verify · Sign-off ☐ · Notes: _______
88+
- **Owner:** _______ · Verify ✅ (confirmed real @ `main`) · Sign-off ☐ · Notes: Fixed — translator now rejects unknown `$`-operators (blocks `$where`/`$function`); +4 tests. Awaiting human sign-off. (Still confirm whether MongoDB is a v1 driver.)
8989

9090
### P0-5 — Realtime & feed are in-memory only (no cluster coordination)
9191
- **Area:** `service-realtime` (`in-memory-realtime-adapter.ts`), `service-feed`

packages/plugins/driver-mongodb/src/mongodb-filter.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,22 @@ describe('MongoDB Filter Translator', () => {
180180
});
181181
});
182182
});
183+
184+
describe('operator allowlist (P0-4)', () => {
185+
it('rejects $where (server-side JS execution)', () => {
186+
expect(() => translateFilter({ name: { $where: 'this.x == 1' } })).toThrow(/unsupported filter operator '\$where'/);
187+
});
188+
189+
it('rejects $function', () => {
190+
expect(() => translateFilter({ name: { $function: { body: 'fn', args: [], lang: 'js' } } })).toThrow(/unsupported filter operator/);
191+
});
192+
193+
it('rejects unknown $-operators rather than passing them through', () => {
194+
expect(() => translateFilter({ name: { $expr: 1 } })).toThrow(/unsupported filter operator '\$expr'/);
195+
});
196+
197+
it('still accepts every allowlisted field operator', () => {
198+
expect(() => translateFilter({ a: { $eq: 1 }, b: { $in: [1, 2] }, c: { $contains: 'x' }, d: { $exists: true } })).not.toThrow();
199+
});
200+
});
183201
});

packages/plugins/driver-mongodb/src/mongodb-filter.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,11 @@ function translateFieldOperators(ops: Record<string, unknown>): Record<string, u
165165
break;
166166

167167
default:
168-
// Pass through unknown operators as-is
169-
result[op] = value;
168+
// Reject unknown operators instead of passing them through (P0). Keys
169+
// like `$where` / `$function` / `$expr` / `$accumulator` would reach
170+
// MongoDB and execute server-side JavaScript or bypass query intent.
171+
// Every legitimate ObjectQL field operator is allowlisted above.
172+
throw new Error(`[mongodb] unsupported filter operator '${op}'`);
170173
}
171174
}
172175

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
4+
import { SqlDriver } from '../src/index.js';
5+
6+
/**
7+
* P0-3 regression: the `contains` / `$contains` operator must escape LIKE
8+
* metacharacters (`%` / `_`) in the user value so they match literally. An
9+
* unescaped `%` would expand to `%%%` and match every row — a filter bypass.
10+
*/
11+
describe('SqlDriver — contains escapes LIKE metacharacters (P0-3)', () => {
12+
let driver: SqlDriver;
13+
let knex: any;
14+
15+
beforeEach(async () => {
16+
driver = new SqlDriver({
17+
client: 'better-sqlite3',
18+
connection: { filename: ':memory:' },
19+
useNullAsDefault: true,
20+
});
21+
knex = (driver as any).knex;
22+
await knex.schema.createTable('docs', (t: any) => {
23+
t.string('id').primary();
24+
t.string('title');
25+
});
26+
await knex('docs').insert([
27+
{ id: '1', title: '50% off sale' }, // literal %
28+
{ id: '2', title: 'plain title' }, // no metacharacter
29+
{ id: '3', title: 'a_b underscore' }, // literal _
30+
]);
31+
});
32+
33+
afterEach(async () => {
34+
await knex.destroy();
35+
});
36+
37+
it('a "%" value matches only rows containing a literal %, not every row', async () => {
38+
const r = await driver.find('docs', { where: { title: { $contains: '%' } } });
39+
expect(r.map((x: any) => x.id)).toEqual(['1']);
40+
});
41+
42+
it('a "_" value matches only rows containing a literal _, not any single char', async () => {
43+
const r = await driver.find('docs', { where: { title: { $contains: '_' } } });
44+
expect(r.map((x: any) => x.id)).toEqual(['3']);
45+
});
46+
47+
it('an ordinary substring still matches normally', async () => {
48+
const r = await driver.find('docs', { where: { title: { $contains: 'sale' } } });
49+
expect(r.map((x: any) => x.id)).toEqual(['1']);
50+
});
51+
});

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1562,7 +1562,7 @@ export class SqlDriver implements IDataDriver {
15621562
const methodNotIn = nextJoin === 'or' ? 'orWhereNotIn' : 'whereNotIn';
15631563

15641564
if (op === 'contains') {
1565-
b[method](field, 'like', `%${value}%`);
1565+
this.applyContainsLike(b, method, field, value);
15661566
return;
15671567
}
15681568

@@ -1596,6 +1596,20 @@ export class SqlDriver implements IDataDriver {
15961596
}
15971597
}
15981598

1599+
/**
1600+
* Apply a `contains` substring match as a parameterized `LIKE '%…%'`, escaping
1601+
* the LIKE metacharacters `%` / `_` (and the escape char `\`) in the user value
1602+
* so they match literally instead of acting as wildcards — otherwise a value of
1603+
* `%` matches every row (a filter-bypass, P0). Binds an explicit `ESCAPE '\'`
1604+
* because SQLite does not honour a default escape character (MySQL/Postgres do,
1605+
* but the explicit clause is correct for all three).
1606+
*/
1607+
private applyContainsLike(builder: any, method: string, field: string, value: unknown): void {
1608+
const escaped = String(value).replace(/[\\%_]/g, '\\$&');
1609+
const rawMethod = method.startsWith('or') ? 'orWhereRaw' : 'whereRaw';
1610+
builder[rawMethod]('?? LIKE ? ESCAPE ?', [field, `%${escaped}%`, '\\']);
1611+
}
1612+
15991613
protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp: 'and' | 'or' = 'and', tableHint?: string | null) {
16001614
if (!condition || typeof condition !== 'object') return;
16011615
const table = tableHint ?? this.tableNameForBuilder(builder);
@@ -1653,7 +1667,7 @@ export class SqlDriver implements IDataDriver {
16531667
break;
16541668
}
16551669
case '$contains':
1656-
(builder as any)[method](field, 'like', `%${opValue}%`);
1670+
this.applyContainsLike(builder, method, field, opValue);
16571671
break;
16581672
default:
16591673
(builder as any)[method](field, coerced);

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,4 +1143,39 @@ describe('AuthManager', () => {
11431143
expect(als.getStore()).toBeUndefined();
11441144
});
11451145
});
1146+
1147+
describe('generateSecret – production secret guard (P0-1)', () => {
1148+
const ENV_KEYS = ['NODE_ENV', 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET'] as const;
1149+
let saved: Record<string, string | undefined>;
1150+
1151+
beforeEach(() => {
1152+
saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
1153+
for (const k of ['OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET']) delete process.env[k];
1154+
});
1155+
afterEach(() => {
1156+
for (const k of ENV_KEYS) {
1157+
if (saved[k] === undefined) delete process.env[k];
1158+
else process.env[k] = saved[k];
1159+
}
1160+
});
1161+
1162+
it('throws (fails boot) in production when no secret is configured', () => {
1163+
process.env.NODE_ENV = 'production';
1164+
const m = new AuthManager({ baseUrl: 'http://localhost:3000' } as any);
1165+
expect(() => (m as any).generateSecret()).toThrow(/OS_AUTH_SECRET is required in production/);
1166+
});
1167+
1168+
it('falls back to an ephemeral dev secret outside production', () => {
1169+
process.env.NODE_ENV = 'development';
1170+
const m = new AuthManager({ baseUrl: 'http://localhost:3000' } as any);
1171+
expect((m as any).generateSecret()).toMatch(/^dev-secret-/);
1172+
});
1173+
1174+
it('uses OS_AUTH_SECRET when set, even in production', () => {
1175+
process.env.NODE_ENV = 'production';
1176+
process.env.OS_AUTH_SECRET = 'a-strong-production-secret-value';
1177+
const m = new AuthManager({ baseUrl: 'http://localhost:3000' } as any);
1178+
expect((m as any).generateSecret()).toBe('a-strong-production-secret-value');
1179+
});
1180+
});
11461181
});

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,23 +1051,28 @@ export class AuthManager {
10511051
*/
10521052
private generateSecret(): string {
10531053
const envSecret = readEnvWithDeprecation('OS_AUTH_SECRET', ['AUTH_SECRET', 'BETTER_AUTH_SECRET']);
1054-
1055-
if (!envSecret) {
1056-
// In production, a secret MUST be provided
1057-
// For development/testing, we'll use a fallback but warn about it
1058-
const fallbackSecret = 'dev-secret-' + Date.now();
1059-
1060-
console.warn(
1061-
'⚠️ WARNING: No OS_AUTH_SECRET environment variable set! ' +
1062-
'Using a temporary development secret. ' +
1063-
'This is NOT secure for production use. ' +
1064-
'Please set OS_AUTH_SECRET in your environment variables.'
1054+
if (envSecret) return envSecret;
1055+
1056+
// No secret configured. In production this is FATAL: a predictable
1057+
// `dev-secret-<timestamp>` makes session tokens forgeable (session
1058+
// forgery). Refuse to boot rather than run insecurely.
1059+
if (process.env.NODE_ENV === 'production') {
1060+
throw new Error(
1061+
'[auth] OS_AUTH_SECRET is required in production but is not set. ' +
1062+
'Refusing to boot with a temporary development secret — session tokens ' +
1063+
'would be forgeable. Set OS_AUTH_SECRET to a strong random value.'
10651064
);
1066-
1067-
return fallbackSecret;
10681065
}
10691066

1070-
return envSecret;
1067+
// Development / test only: fall back to an ephemeral secret, loudly.
1068+
const fallbackSecret = 'dev-secret-' + Date.now();
1069+
console.warn(
1070+
'⚠️ WARNING: No OS_AUTH_SECRET environment variable set! ' +
1071+
'Using a temporary development secret. ' +
1072+
'This is NOT secure for production use. ' +
1073+
'Please set OS_AUTH_SECRET in your environment variables.'
1074+
);
1075+
return fallbackSecret;
10711076
}
10721077

10731078
/**

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('SecurityPlugin', () => {
2323
const plugin = new SecurityPlugin();
2424
const manifestService = { register: vi.fn() };
2525
const ctx: any = {
26-
logger: { info: vi.fn(), warn: vi.fn() },
26+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
2727
registerService: vi.fn(),
2828
getService: vi.fn().mockImplementation((name: string) => {
2929
if (name === 'manifest') return manifestService;
@@ -41,7 +41,7 @@ describe('SecurityPlugin', () => {
4141
const plugin = new SecurityPlugin();
4242
const manifestService = { register: vi.fn() };
4343
const ctx: any = {
44-
logger: { info: vi.fn(), warn: vi.fn() },
44+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
4545
registerService: vi.fn(),
4646
getService: vi.fn().mockImplementation((name: string) => {
4747
if (name === 'manifest') return manifestService;
@@ -57,7 +57,7 @@ describe('SecurityPlugin', () => {
5757
const plugin = new SecurityPlugin();
5858
const manifestService = { register: vi.fn() };
5959
const ctx: any = {
60-
logger: { info: vi.fn(), warn: vi.fn() },
60+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
6161
registerService: vi.fn(),
6262
getService: vi.fn().mockImplementation((name: string) => {
6363
if (name === 'manifest') return manifestService;
@@ -74,7 +74,7 @@ describe('SecurityPlugin', () => {
7474
const registerMiddleware = vi.fn();
7575
const manifestService = { register: vi.fn() };
7676
const ctx: any = {
77-
logger: { info: vi.fn(), warn: vi.fn() },
77+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
7878
registerService: vi.fn(),
7979
getService: vi.fn().mockImplementation((name: string) => {
8080
if (name === 'manifest') return manifestService;
@@ -127,7 +127,7 @@ describe('SecurityPlugin', () => {
127127
services['org-scoping'] = { name: 'com.objectstack.org-scoping' };
128128
}
129129
const ctx: any = {
130-
logger: { info: vi.fn(), warn: vi.fn() },
130+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
131131
registerService: vi.fn(),
132132
getService: (name: string) => {
133133
if (!(name in services)) throw new Error(`service not registered: ${name}`);
@@ -344,6 +344,38 @@ describe('SecurityPlugin', () => {
344344
});
345345
});
346346

347+
it('fails CLOSED when permission resolution throws — denies, never bypasses (P0-2)', async () => {
348+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
349+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] });
350+
await plugin.init(harness.ctx);
351+
await plugin.start(harness.ctx);
352+
// Simulate the permission/metadata subsystem failing mid-resolution.
353+
(plugin as any).permissionEvaluator = {
354+
resolvePermissionSets: async () => { throw new Error('metadata service unavailable'); },
355+
};
356+
const opCtx: any = {
357+
object: 'task',
358+
operation: 'find',
359+
data: {},
360+
context: { userId: 'u1', tenantId: 'org-1', roles: ['member'], permissions: [] },
361+
};
362+
// Resolution failed → the request must be DENIED, not waved through.
363+
await expect(harness.run(opCtx)).rejects.toThrow(/permission subsystem unavailable/);
364+
expect(harness.ctx.logger.error).toHaveBeenCalled();
365+
});
366+
367+
it('a system operation still bypasses security regardless (P0-2)', async () => {
368+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
369+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] });
370+
await plugin.init(harness.ctx);
371+
await plugin.start(harness.ctx);
372+
(plugin as any).permissionEvaluator = {
373+
resolvePermissionSets: async () => { throw new Error('should not be called'); },
374+
};
375+
const opCtx: any = { object: 'task', operation: 'find', context: { isSystem: true } };
376+
await expect(harness.run(opCtx)).resolves.toBeDefined(); // bypass short-circuits before resolution
377+
});
378+
347379
it('FLS write — multiple forbidden fields are all listed in the error', async () => {
348380
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
349381
const harness = makeMiddlewareCtx({

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,22 @@ export class SecurityPlugin implements Plugin {
307307
permissionSets = fallback;
308308
}
309309
} catch (e) {
310-
// If metadata service is misconfigured, log and continue without permission checks
311-
// rather than blocking all operations
312-
return next();
310+
// Fail CLOSED. A permission-resolution failure must DENY the request,
311+
// never bypass the checks (that would let a degraded metadata service
312+
// expose every tenant's data). System/bootstrap operations already
313+
// short-circuited above (`opCtx.context?.isSystem`), so reaching here
314+
// means an authenticated user request whose RBAC/RLS could not be
315+
// resolved — deny it and alert.
316+
ctx.logger.error(
317+
`[security] permission resolution failed for operation '${opCtx.operation}' on ` +
318+
`object '${opCtx.object}' (user ${opCtx.context?.userId ?? 'unknown'}) — ` +
319+
`denying request (fail-closed)`,
320+
e instanceof Error ? e : new Error(String(e)),
321+
);
322+
throw new PermissionDeniedError(
323+
`[Security] Access denied: permission subsystem unavailable for ` +
324+
`operation '${opCtx.operation}' on object '${opCtx.object}'`,
325+
);
313326
}
314327

315328
// 2. CRUD permission check

0 commit comments

Comments
 (0)