Skip to content

Commit 49b9adc

Browse files
authored
feat(objectql): reject aggregations over secret/password fields (#3171) (#3187)
aggregate() now refuses to reference a secret/password field (measure or groupBy). find/findOne mask credential fields so plaintext never leaves the engine, but aggregate had no equivalent guard — a GROUP BY / MIN / MAX / array_agg would surface the stored value, and post-hoc masking would corrupt group keys. The gate keys off a new unconditional collectCredentialFields collector (secret OR password, ignoring managedBy — aggregating a credential is never legitimate, even on a better-auth object). Fail-closed. Recorded as the follow-up in ADR-0100. Closes #3171.
1 parent 780b4b5 commit 49b9adc

5 files changed

Lines changed: 117 additions & 0 deletions

File tree

packages/objectql/src/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export {
7676
parseSecretRef,
7777
collectSecretFields,
7878
collectMaskedReadFields,
79+
collectCredentialFields,
7980
} from './secret-fields.js';
8081

8182
// Utilities

packages/objectql/src/engine.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { ICryptoProvider, CryptoHandle } from '@objectstack/spec/contracts'
2020
import {
2121
collectSecretFields,
2222
collectMaskedReadFields,
23+
collectCredentialFields,
2324
makeSecretRef,
2425
parseSecretRef,
2526
isSecretRef,
@@ -2919,8 +2920,49 @@ export class ObjectQL implements IDataEngine {
29192920
return opCtx.result as number;
29202921
}
29212922

2923+
/**
2924+
* Fail-closed guard (ADR-0100 / #3171): refuse to aggregate over a credential
2925+
* field. `secret`/`password` values are masked on the generic read path so
2926+
* plaintext never leaves the engine, but `aggregate()` has no equivalent mask
2927+
* — a GROUP BY / MIN / MAX / array_agg over such a column would surface the
2928+
* stored `secret:<id>` ref or the password value, and post-hoc masking would
2929+
* corrupt group keys. So we reject instead. The check is unconditional
2930+
* (ignores `managedBy`): aggregating a credential is never legitimate, even on
2931+
* a better-auth object, where it would be an inference oracle over hashes.
2932+
*
2933+
* Only the two output-bearing positions on `EngineAggregateOptions` carry
2934+
* field names: `aggregations[].field` (skip COUNT(*) — undefined or '*') and
2935+
* `groupBy[]` (a string, or a `{ field }` bucket object).
2936+
*/
2937+
private rejectCredentialAggregation(object: string, query: EngineAggregateOptions): void {
2938+
const schema = this._registry.getObject(object);
2939+
const credentialFields = collectCredentialFields(schema);
2940+
if (credentialFields.length === 0) return;
2941+
2942+
const referenced = new Set<string>();
2943+
for (const agg of query?.aggregations ?? []) {
2944+
const field = (agg as { field?: string })?.field;
2945+
if (field && field !== '*') referenced.add(field);
2946+
}
2947+
for (const g of (query?.groupBy as unknown[]) ?? []) {
2948+
const field = typeof g === 'string' ? g : (g as { field?: string })?.field;
2949+
if (field) referenced.add(field);
2950+
}
2951+
2952+
const hit = credentialFields.filter((f) => referenced.has(f));
2953+
if (hit.length > 0) {
2954+
throw new Error(
2955+
`Cannot aggregate credential field(s) ${hit.map((f) => `"${object}.${f}"`).join(', ')}: `
2956+
+ 'secret/password fields are masked on read so plaintext never leaves the engine, and '
2957+
+ 'aggregating them (group-by, min/max, array_agg, …) would surface the stored value. '
2958+
+ 'Refusing (fail-closed) — see ADR-0100 / #3171.',
2959+
);
2960+
}
2961+
}
2962+
29222963
async aggregate(object: string, query: EngineAggregateOptions, options?: EngineReadOptions): Promise<any[]> {
29232964
object = this.resolveObjectName(object);
2965+
this.rejectCredentialAggregation(object, query);
29242966
const driver = this.getDriver(object);
29252967
this.logger.debug(`Aggregate on ${object} using ${driver.name}`, query);
29262968

packages/objectql/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export {
103103
parseSecretRef,
104104
collectSecretFields,
105105
collectMaskedReadFields,
106+
collectCredentialFields,
106107
} from './secret-fields.js';
107108

108109
// Export Utilities

packages/objectql/src/secret-fields.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,3 +320,51 @@ describe('objectql password-field masking (ADR-0100)', () => {
320320
expect(viaOne.password).toBe('hashed-by-auth');
321321
});
322322
});
323+
324+
describe('objectql aggregate() rejects credential fields (ADR-0100 / #3171)', () => {
325+
it('rejects a `secret` field used as an aggregation measure', async () => {
326+
const { engine } = await buildEngine(true);
327+
await expect(
328+
engine.aggregate('ext_datasource', { aggregations: [{ function: 'max', field: 'db_password', alias: 'x' }] as any }),
329+
).rejects.toThrow(/credential field.*db_password/i);
330+
});
331+
332+
it('rejects a `secret` field used as a string groupBy dimension', async () => {
333+
const { engine } = await buildEngine(true);
334+
await expect(
335+
engine.aggregate('ext_datasource', { aggregations: [{ function: 'count', alias: 'n' }], groupBy: ['db_password'] } as any),
336+
).rejects.toThrow(/db_password/);
337+
});
338+
339+
it('rejects a `secret` field used as a structured {field} groupBy bucket', async () => {
340+
const { engine } = await buildEngine(true);
341+
await expect(
342+
engine.aggregate('ext_datasource', { aggregations: [{ function: 'count', alias: 'n' }], groupBy: [{ field: 'db_password' }] } as any),
343+
).rejects.toThrow(/db_password/);
344+
});
345+
346+
it('does NOT reject when the credential field is not referenced (no false positive)', async () => {
347+
const { engine } = await buildEngine(true);
348+
await engine.insert('ext_datasource', { name: 'pg', db_password: 's3cr3t' });
349+
// COUNT(*) touches no credential column — the object merely *has* one.
350+
await expect(
351+
engine.aggregate('ext_datasource', { aggregations: [{ function: 'count', alias: 'n' }] } as any),
352+
).resolves.toBeDefined();
353+
});
354+
355+
it('rejects a generic `password` field used as a groupBy dimension', async () => {
356+
const { engine } = await buildPasswordEngine();
357+
await expect(
358+
engine.aggregate('device', { aggregations: [{ function: 'count', alias: 'n' }], groupBy: ['admin_password'] } as any),
359+
).rejects.toThrow(/admin_password/);
360+
});
361+
362+
it('rejects even on a better-auth object — read-masking is exempt there, aggregation is NOT', async () => {
363+
const { engine } = await buildPasswordEngine();
364+
// authy_user is managedBy:'better-auth' (password NOT masked on read), but
365+
// aggregating the credential is still refused (unconditional collector).
366+
await expect(
367+
engine.aggregate('authy_user', { aggregations: [{ function: 'max', field: 'password', alias: 'x' }] as any }),
368+
).rejects.toThrow(/password/);
369+
});
370+
});

packages/objectql/src/secret-fields.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,28 @@ export function collectMaskedReadFields(schema: ServiceObject | undefined | null
9797
}
9898
return out;
9999
}
100+
101+
/**
102+
* Collect the names of every credential-bearing field on an object — `secret`
103+
* OR `password` — **unconditionally**, ignoring `managedBy`.
104+
*
105+
* This differs from {@link collectMaskedReadFields} on purpose. Read-masking
106+
* exempts `password` on `managedBy: 'better-auth'` objects so login reads still
107+
* see the stored value; but *aggregating* a credential field must never be
108+
* allowed, even on a better-auth object — a GROUP BY / MIN / MAX over a password
109+
* column is an inference oracle regardless of who owns the table. So the
110+
* aggregate-rejection gate keys off this stricter, exemption-free collector,
111+
* keeping the two concerns independent (they must not drift). See ADR-0100 / #3171.
112+
*
113+
* Returns an empty array when the schema has no fields or no credential fields,
114+
* so callers can fast-path on `length === 0`.
115+
*/
116+
export function collectCredentialFields(schema: ServiceObject | undefined | null): string[] {
117+
const fields = (schema as any)?.fields as Record<string, { type?: string }> | undefined;
118+
if (!fields) return [];
119+
const out: string[] = [];
120+
for (const [name, def] of Object.entries(fields)) {
121+
if (def && (def.type === 'secret' || def.type === 'password')) out.push(name);
122+
}
123+
return out;
124+
}

0 commit comments

Comments
 (0)