Files
gbrain/test/transcription-injection.test.ts
Garry TanandClaude Opus 4.8 dde1132a29 v0.42.55.0 fix(security): dotfile/skills/slug confinement + DCR consent default + schema-lint migration (#418 #419 #245 #1353 #1647 #171 #1385) (#2399)
* fix(security): confine routing dotfiles, skills dir, slugs, and transcription exec

Shared src/core/path-confine.ts consolidates the realpath-containment idiom
(moved from sources-ops.ts) and adds isTrustedDotfile + isWriteTargetContained.

- .gbrain-source (source-resolver) and .gbrain-mount (brain-resolver) walk-up
  dotfiles are now lstat trust-gated: a symlink, foreign-owned, or world-writable
  file is refused on multi-user hosts (#418), fail-closed on stat error.
- resolveWorkspaceSkillsDir + every skills-dir tier (env, cwd_walk_up, repo_root,
  cwd_skills, install_path) route through realpath containment so a symlinked
  workspace/skills can't escape the declared workspace (#419).
- resolveSourceId/resolveBrainId realpath both sides of the registered local_path
  / mount prefix match so a symlinked cwd can't misattribute source/brain.
- validateSlug rejects NUL/control, bidi/RTL overrides, backslashes, and
  URL-encoded path separators at the shared putPage/updateSlug chokepoint;
  write-through confirms the file path stays within the source tree.
- transcribeLargeFile uses execFileSync arg-arrays + fs.rmSync (no shell), so a
  path with shell metacharacters is never parsed by a shell (#245).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): default dynamic-registration clients to authorization_code

Self-registered DCR clients (the unauthenticated network registration path)
previously defaulted to the client_credentials grant, which bypasses the
/authorize consent screen. They now default to authorization_code; an explicit
client_credentials request is rejected with invalid_client_metadata unless the
operator opts in with the new --enable-dcr-insecure flag. A loud stderr WARNING
prints at startup whenever DCR is enabled (#1353). Manual CLI/admin client
registration is unchanged (operator-trusted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): schema-lint hardening migration (search_path + view security_invoker)

Migration v120 brings existing brains to the same posture as fresh installs:

- ALTER VIEW page_links SET (security_invoker = on) on Postgres so the view
  honors the caller's RLS instead of the owner's (the view-through-RLS bypass).
- ALTER FUNCTION ... SET search_path on the gbrain-owned trigger/event functions
  (both engines, IF EXISTS so engine-only functions are skipped; body untouched,
  so the load-bearing auto_enable_rls event trigger is unchanged). Closes #171.
- Broaden the BYPASSRLS preflight in the historical RLS migration gates to honor
  superuser and inherited-role BYPASSRLS, so a superuser-connected fresh install
  no longer aborts (#1385).

Fresh-install function definitions in schema.sql / pglite-schema.ts are
born-correct (regenerated schema-embedded.ts). scripts/check-search-path.sh is a
new CI guard (wired into verify) that fails if a trigger function in the schema
base files is added without SET search_path. Postgres-only assertions live in the
bootstrap E2E; the PGLite path is covered by test/migration-v120.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* v0.42.55.0 fix(security): dotfile/skills/slug confinement, DCR consent default, schema-lint migration

Bump VERSION + package.json to 0.42.55.0 and add the CHANGELOG entry for the
security-hardening wave (#418 #419 #245 #1353 #1647 #171 #1385).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(security): note the DCR consent default in SECURITY.md (#1353)

The "disable client_credentials, only allow authorization_code" guidance is now
the built-in DCR default; document the new --enable-dcr-insecure escape hatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(todos): add takes_search + code_def to the federated by-slug P1 (#2200)

The v0.42.55.0 eng-review codex pass flagged takes_search (holder-allowlist only)
and code_def (brain-wide raw SQL over content_chunks) as remaining same-class
surfaces. Noted on the existing #2200 P1 follow-up, with the caveat that the
#2399 close-list deliberately keeps #1371/#2200 open until this lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): correct plpgsql alias collision in #1385 BYPASSRLS gate (real-PG)

The broadened BYPASSRLS preflight aliased `pg_roles r`, but several RLS DO-blocks
already declare `r record` for their backfill FOR loop, so plpgsql resolved
`r.oid`/`r.rolbypassrls` to the unassigned record variable → "record \"r\" is
not assigned yet" on real Postgres (PGLite tolerated it; the DATABASE_URL-gated
e2e jobs are the backstop). Renamed the subquery alias to `pr` at all 10
migrate.ts sites; also broadened the schema.sql base RLS gate the same way (with
the `pr` alias) for #1385 consistency on superuser fresh installs, and
regenerated schema-embedded.ts.

Also fixes a PRE-EXISTING engine-parity bug (confirmed failing on clean
origin/master): the relationalFanout shape compared `canonical_chunk_id`, a
serial id that diverges between a fresh PGLite engine and a shared Postgres DB
(setupDB TRUNCATEs without RESTART IDENTITY). Compare its presence, not the
exact value.

Validated on real Postgres (pgvector/pg16): migration v120 applies, the v35 RLS
backfill runs, and engine-parity + postgres-bootstrap + jsonb-parity are green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:00:58 -07:00

66 lines
3.3 KiB
TypeScript

/**
* Security test for #245 — the large-file transcription path must not pass
* caller-controlled audio paths through a shell. transcribeLargeFile now uses
* execFileSync with argument arrays (+ fs.rmSync for cleanup), so a path
* containing shell metacharacters is a literal argv element, never parsed.
*
* transcription.ts is a dead-internally-but-PUBLISHED export (gbrain/transcription),
* so an external programmatic consumer can still call transcribe() with an
* attacker-influenced path — hence the harden over delete.
*/
import { describe, test, expect } from 'bun:test';
import {
mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync,
} from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
import { transcribe } from '../src/core/transcription.ts';
function ffmpegAvailable(): boolean {
try { execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });
execFileSync('ffprobe', ['-version'], { stdio: 'pipe' });
return true; } catch { return false; }
}
describe('transcription — command injection (#245)', () => {
// Source-level guard (deterministic, no ffmpeg dependency): the shell-string
// exec forms must be gone. Mirrors the repo's check-*.sh guard philosophy.
test('source uses execFileSync arg-arrays, never shell execSync', () => {
const src = readFileSync(new URL('../src/core/transcription.ts', import.meta.url), 'utf8');
// `\bexecSync(` matches the dangerous shell-string call but NOT execFileSync.
expect(src).not.toMatch(/\bexecSync\s*\(/); // no shell command strings
expect(src).toMatch(/execFileSync\(/); // arg-array exec
expect(src).toMatch(/rmSync\(/); // fs cleanup, not shell remove
});
// Behavioral: a >25MB file whose NAME contains a $(...) payload. Under the old
// shell-string code the double-quoted interpolation command-substitutes the
// payload (creating the sentinel) before ffprobe runs; under execFileSync the
// payload is a literal filename, so no sentinel is ever created.
test('a $(...) payload in the audio path does not spawn a shell', async () => {
if (!ffmpegAvailable()) return; // path requires ffmpeg/ffprobe; skip otherwise
// Sentinel must be a slash-free name so it's a legal single filename segment;
// a shell `touch <name>` would create it in process.cwd(), so we check there.
const sentinelName = `gbrain-inj-sentinel-${process.pid}-${Date.now()}`;
const sentinelPath = join(process.cwd(), sentinelName);
if (existsSync(sentinelPath)) rmSync(sentinelPath, { force: true });
const dir = mkdtempSync(join(tmpdir(), 'transcribe-inj-'));
// Payload touches the sentinel IF a shell ever parses the path.
const evil = join(dir, `clip$(touch ${sentinelName}).mp3`);
writeFileSync(evil, Buffer.alloc(26 * 1024 * 1024)); // >25MB → large-file path
try {
await transcribe(evil, { apiKey: 'dummy', provider: 'groq' });
} catch {
// Expected: ffprobe/ffmpeg reject the garbage file, or transcribeFile has
// no real key. We only care that no shell ran.
} finally {
rmSync(dir, { recursive: true, force: true });
}
const pwned = existsSync(sentinelPath);
if (pwned) rmSync(sentinelPath, { force: true });
expect(pwned).toBe(false);
}, 30_000);
});