mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
* 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>
66 lines
3.3 KiB
TypeScript
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);
|
|
});
|