@everystack/server 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/db-doctor.ts +99 -7
- package/src/plugin.ts +17 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -139,8 +139,8 @@
|
|
|
139
139
|
"sst": "4.13.1",
|
|
140
140
|
"ts-jest": "29.4.9",
|
|
141
141
|
"typescript": "5.9.3",
|
|
142
|
-
"@everystack/auth": "0.
|
|
143
|
-
"@everystack/cli": "0.3.
|
|
142
|
+
"@everystack/auth": "0.3.0",
|
|
143
|
+
"@everystack/cli": "0.3.3"
|
|
144
144
|
},
|
|
145
145
|
"scripts": {
|
|
146
146
|
"test": "jest",
|
package/src/db-doctor.ts
CHANGED
|
@@ -62,6 +62,20 @@ export interface DoctorProbe {
|
|
|
62
62
|
* `USING(true)` role like the master). Absent when there is no RLS table to probe.
|
|
63
63
|
*/
|
|
64
64
|
apiAmbientRead?: { table: string; accessible: boolean };
|
|
65
|
+
/**
|
|
66
|
+
* Every SECURITY DEFINER function (schema, name, whether search_path is pinned). The
|
|
67
|
+
* `functions` teeth read this: for each schema that owns a `writtenBy: 'functions'` table
|
|
68
|
+
* (passed as `functionSchemas`), the package's SECDEF functions must exist and each must
|
|
69
|
+
* pin its search_path. Absent on an older probe (the check is then skipped).
|
|
70
|
+
*/
|
|
71
|
+
secdefFunctions?: SecdefFn[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A SECURITY DEFINER function and whether its `search_path` is pinned. */
|
|
75
|
+
export interface SecdefFn {
|
|
76
|
+
schema: string;
|
|
77
|
+
name: string;
|
|
78
|
+
hasSearchPath: boolean;
|
|
65
79
|
}
|
|
66
80
|
|
|
67
81
|
export type CheckStatus = 'pass' | 'fail' | 'warn';
|
|
@@ -106,6 +120,29 @@ WHERE c.relkind = 'r'
|
|
|
106
120
|
ORDER BY n.nspname, c.relname
|
|
107
121
|
`.trim();
|
|
108
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Every SECURITY DEFINER function with whether its `search_path` is pinned. A SECDEF
|
|
125
|
+
* function runs as its owner (above RLS), so an unpinned `search_path` is a hijack vector:
|
|
126
|
+
* a caller can shadow an unqualified name the body calls and run code as the owner. This is
|
|
127
|
+
* the exact bug class that bit dev (migration 0022 went unapplied via a journal gap, leaving
|
|
128
|
+
* the auth SECDEF functions mutable) — the `functions` teeth read this to catch it on the
|
|
129
|
+
* deployed database, not just in the migration SQL (which `security:audit` already covers).
|
|
130
|
+
*/
|
|
131
|
+
export const SECDEF_FUNCTIONS_SQL = `
|
|
132
|
+
SELECT
|
|
133
|
+
n.nspname AS schema,
|
|
134
|
+
p.proname AS name,
|
|
135
|
+
EXISTS (
|
|
136
|
+
SELECT 1 FROM unnest(COALESCE(p.proconfig, '{}'::text[]) ) cfg WHERE cfg LIKE 'search_path=%'
|
|
137
|
+
) AS has_search_path
|
|
138
|
+
FROM pg_proc p
|
|
139
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
140
|
+
WHERE p.prosecdef
|
|
141
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
142
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
143
|
+
ORDER BY n.nspname, p.proname
|
|
144
|
+
`.trim();
|
|
145
|
+
|
|
109
146
|
/** Coerce a Postgres boolean (true | 't' | 'true' | 1) to a JS boolean. */
|
|
110
147
|
export function pgBool(v: unknown): boolean {
|
|
111
148
|
return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
|
|
@@ -170,12 +207,20 @@ ORDER BY n.nspname, c.relname;
|
|
|
170
207
|
/** Options for the report builder. */
|
|
171
208
|
export interface DoctorReportOptions {
|
|
172
209
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
210
|
+
* Tables that are intentionally ENABLE-not-FORCE: a table whose owner (the worker, or a
|
|
211
|
+
* SECURITY DEFINER function) must read/write it, where FORCE would block the owner. These
|
|
212
|
+
* are not flagged by the FORCE check. Read from each model's `writtenBy` declaration via
|
|
213
|
+
* `deriveForceRlsCarveouts(models)` — including `auth.users` and the six other `auth.*`
|
|
214
|
+
* tables (all `writtenBy: 'functions'`), `ops.runs`, `dist.blobs`, and the notification
|
|
215
|
+
* tables — so the list can't drift from the contract.
|
|
177
216
|
*/
|
|
178
217
|
forceRlsCarveouts?: string[];
|
|
218
|
+
/**
|
|
219
|
+
* Schemas that own a `writtenBy: 'functions'` table (e.g. `['auth']`), via
|
|
220
|
+
* `deriveFunctionSchemas(models)`. The `functions` teeth assert each such schema has
|
|
221
|
+
* SECDEF functions and that every one pins its `search_path`. Omitted/empty skips the check.
|
|
222
|
+
*/
|
|
223
|
+
functionSchemas?: string[];
|
|
179
224
|
}
|
|
180
225
|
|
|
181
226
|
/** Build the green/red report from a gathered probe. Pure. */
|
|
@@ -272,9 +317,11 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
272
317
|
// ENABLE-but-not-FORCE: the API/SSR roles are non-owners and are still gated by its
|
|
273
318
|
// policies regardless of FORCE. `auth.users` (the @everystack/auth credential table) is
|
|
274
319
|
// the framework's one such table.
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
320
|
+
// The carveouts come entirely from the models' `writtenBy` declarations
|
|
321
|
+
// (deriveForceRlsCarveouts): auth.users and the six other auth.* tables (functions),
|
|
322
|
+
// ops.runs and dist.blobs (worker), the notification tables. No hand-list, no framework
|
|
323
|
+
// default that could drift from — or fall short of — the modeled set.
|
|
324
|
+
const FORCE_RLS_CARVEOUTS = new Set(options.forceRlsCarveouts ?? []);
|
|
278
325
|
const allUnforced = probe.forceRls.filter((r) => r.enabled && !r.forced);
|
|
279
326
|
const carved = allUnforced.filter((r) => FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
|
|
280
327
|
const unforced = allUnforced.filter((r) => !FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
|
|
@@ -302,6 +349,42 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
302
349
|
});
|
|
303
350
|
}
|
|
304
351
|
|
|
352
|
+
// The `functions` teeth — the other half of `writtenBy: 'functions'`. A table written only
|
|
353
|
+
// by SECURITY DEFINER functions is ENABLE-not-FORCE (carved out above) precisely BECAUSE the
|
|
354
|
+
// package's functions run as the owner. That bargain is only safe if those functions exist
|
|
355
|
+
// and each pins its search_path; an unpinned one is an owner-privilege hijack vector. This
|
|
356
|
+
// asserts it on the deployed database — the gate that was missing when 0022 silently went
|
|
357
|
+
// unapplied and left the auth SECDEF functions mutable.
|
|
358
|
+
if (probe.secdefFunctions && (options.functionSchemas?.length ?? 0) > 0) {
|
|
359
|
+
const schemas = new Set(options.functionSchemas);
|
|
360
|
+
const relevant = probe.secdefFunctions.filter((f) => schemas.has(f.schema));
|
|
361
|
+
const unpinned = relevant.filter((f) => !f.hasSearchPath);
|
|
362
|
+
const present = new Set(relevant.map((f) => f.schema));
|
|
363
|
+
const missing = [...schemas].filter((s) => !present.has(s)).sort();
|
|
364
|
+
if (unpinned.length > 0) {
|
|
365
|
+
checks.push({
|
|
366
|
+
name: 'SECURITY DEFINER functions pin search_path',
|
|
367
|
+
status: 'fail',
|
|
368
|
+
detail: `SECDEF function(s) in a functions-written schema have a mutable search_path (owner-privilege hijack vector): ${unpinned.map((f) => `${f.schema}.${f.name}`).join(', ')}`,
|
|
369
|
+
remediation: unpinned.map((f) => `ALTER FUNCTION ${f.schema}.${f.name} SET search_path = ${f.schema}, public, pg_temp;`).join(' ')
|
|
370
|
+
+ ' (Then redeploy so the migration that defines them carries the pin.)',
|
|
371
|
+
});
|
|
372
|
+
} else if (missing.length > 0) {
|
|
373
|
+
checks.push({
|
|
374
|
+
name: 'SECURITY DEFINER functions exist for functions-written tables',
|
|
375
|
+
status: 'fail',
|
|
376
|
+
detail: `no SECDEF functions found in schema(s) that own a writtenBy:'functions' table: ${missing.join(', ')} — the package SQL that writes those tables was not applied`,
|
|
377
|
+
remediation: `Apply the package's functions migration (e.g. everystack db:migrate). A functions-written table with no SECDEF functions cannot be written at all.`,
|
|
378
|
+
});
|
|
379
|
+
} else {
|
|
380
|
+
checks.push({
|
|
381
|
+
name: 'SECURITY DEFINER functions pin search_path',
|
|
382
|
+
status: 'pass',
|
|
383
|
+
detail: `all ${relevant.length} SECDEF function(s) in ${[...present].sort().join(', ')} pin their search_path`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
305
388
|
return {
|
|
306
389
|
api,
|
|
307
390
|
admin,
|
|
@@ -310,6 +393,15 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
310
393
|
};
|
|
311
394
|
}
|
|
312
395
|
|
|
396
|
+
/** Map a SECDEF_FUNCTIONS_SQL row to SecdefFn. */
|
|
397
|
+
export function rowToSecdefFn(row: any): SecdefFn {
|
|
398
|
+
return {
|
|
399
|
+
schema: String(row.schema),
|
|
400
|
+
name: String(row.name),
|
|
401
|
+
hasSearchPath: pgBool(row.has_search_path),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
313
405
|
/** Map a CONN_SQL row to ConnInfo (tolerant of pg driver boolean encodings). */
|
|
314
406
|
export function rowToConnInfo(row: any): ConnInfo {
|
|
315
407
|
return {
|
package/src/plugin.ts
CHANGED
|
@@ -463,11 +463,18 @@ export interface DbPluginOptions {
|
|
|
463
463
|
/** pgSettings callback for console auth context — applied via SET LOCAL when user is present */
|
|
464
464
|
pgSettings?: (user: Record<string, unknown> | null) => Record<string, string>;
|
|
465
465
|
/**
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
466
|
+
* Tables that are intentionally ENABLE-not-FORCE row-level security, so db:doctor does not
|
|
467
|
+
* flag them. Pass `deriveForceRlsCarveouts(models)` — it reads each model's `writtenBy`
|
|
468
|
+
* declaration (auth.users + the six other auth.* tables, ops.runs, dist.blobs, the
|
|
469
|
+
* notification tables), so the list can't drift from the contract.
|
|
469
470
|
*/
|
|
470
471
|
forceRlsCarveouts?: string[];
|
|
472
|
+
/**
|
|
473
|
+
* Schemas that own a `writtenBy: 'functions'` table (e.g. `['auth']`). Pass
|
|
474
|
+
* `deriveFunctionSchemas(models)`. db:doctor's `functions` teeth assert each such schema has
|
|
475
|
+
* SECDEF functions and that every one pins its search_path (the unpinned-search_path gate).
|
|
476
|
+
*/
|
|
477
|
+
functionSchemas?: string[];
|
|
471
478
|
/** Console auth helper config — column names for login/asUser (defaults to everystack conventions) */
|
|
472
479
|
consoleAuth?: {
|
|
473
480
|
usersTable?: string;
|
|
@@ -628,7 +635,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
628
635
|
actions['db:doctor'] = async (_payload, ctx) => {
|
|
629
636
|
const { sql } = await import('drizzle-orm');
|
|
630
637
|
const {
|
|
631
|
-
buildDoctorReport, rowToConnInfo, rowToForceRls, rowToAppGranted,
|
|
638
|
+
buildDoctorReport, rowToConnInfo, rowToForceRls, rowToAppGranted, rowToSecdefFn,
|
|
639
|
+
CONN_SQL, FORCE_RLS_SQL, appGrantsRlsSql, SECDEF_FUNCTIONS_SQL,
|
|
632
640
|
} = await import('./db-doctor');
|
|
633
641
|
const rowsOf = (result: any): any[] => (Array.isArray(result) ? result : result?.rows || []);
|
|
634
642
|
|
|
@@ -649,6 +657,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
649
657
|
// The "we require RLS" set — every app-reachable table + its RLS posture, so a table
|
|
650
658
|
// granted to a request role with RLS off (the PostgREST fail-open shape) is caught.
|
|
651
659
|
const appGrantedRls = rowsOf(await opsDb.execute(sql.raw(appGrantsRlsSql()))).map(rowToAppGranted);
|
|
660
|
+
// SECDEF functions (schema, name, search_path pinned?) — the `functions` teeth read these
|
|
661
|
+
// to assert every SECDEF function in a functions-written schema pins its search_path.
|
|
662
|
+
const secdefFunctions = rowsOf(await opsDb.execute(sql.raw(SECDEF_FUNCTIONS_SQL))).map(rowToSecdefFn);
|
|
652
663
|
|
|
653
664
|
// Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE?
|
|
654
665
|
let apiAmbientRead: { table: string; accessible: boolean } | undefined;
|
|
@@ -664,8 +675,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
664
675
|
}
|
|
665
676
|
|
|
666
677
|
return buildDoctorReport(
|
|
667
|
-
{ api, admin, forceRls, appGrantedRls, apiAmbientRead },
|
|
668
|
-
{ forceRlsCarveouts: options.forceRlsCarveouts },
|
|
678
|
+
{ api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions },
|
|
679
|
+
{ forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
|
|
669
680
|
);
|
|
670
681
|
};
|
|
671
682
|
|