From a96ee3363303fdb0b351626690659ec10b680a1e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 7 Sep 2026 14:39:43 +0100 Subject: [PATCH] fix(cli): enforce content_path project-root containment for every consumer (CLI-2339) CLI-2320 confined auth.email.template.*/auth.email.notification.* content_path resolution to the project root, but only inside config push's own content loader. This centralizes that containment into the shared resolver in legacy-config-validate.ts, so it now protects every consumer with no flag and no opt-out: config push, start (an eager pre-Docker validation pass covering every configured template plus every enabled notification, the same set Kong's mount builder consumes), and the shared config-validation path reached by db/ migration/status/stop/functions deploy/serve/download/gen types/ inspect/bootstrap. Also fixes two bugs found while extending the check's reach: - The canonicalization helper treated any realpath failure as "this path doesn't exist yet" and fell back to lexical resolution - which also covers a dangling symlink, an EACCES-blocked target, or a symlink loop, all of which exist on disk but couldn't be canonicalized. That let an in-root symlink pointing outside the project root bypass containment silently, most seriously for start's Kong mount (a root-privileged, rw Docker bind mount). Fixed by distinguishing "genuinely absent" from "exists but uncanonicalizable" and following a symlink to its real target before checking it. The ancestor walk was also rewritten iteratively to remove a stack-depth limit on deeply nested missing paths. - start's Kong mount resolved and validated a path early, then independently re-derived and used a second, unresolved path much later when building the Docker bind mount - a TOCTOU gap and duplicated resolution logic. The validated, read-verified path is now threaded straight through to the bind-mount builder instead. The rejection message now includes the declared content_path value and the project root (not the fully symlink-dereferenced target, to avoid echoing back where an escaping symlink actually points). Follow-ups filed for the adjacent untrusted-path fields this ticket didn't touch (CLI-2344), and a message-polish gap where an EACCES behind a followed symlink surfaces a raw filesystem error instead of the usual containment message (CLI-2345) - in both cases the path is still rejected, just with a less specific error. --- apps/cli/AGENTS.md | 2 +- ...ig-validate.deep-missing-path.unit.test.ts | 56 ++++ ...legacy-config-validate.parity.unit.test.ts | 18 ++ .../legacy-config-validate.ts | 136 +++++++++- .../legacy-config-validate.unit.test.ts | 256 +++++++++++++++++- .../legacy-local-config-values.ts | 2 +- .../legacy-local-config-values.unit.test.ts | 17 ++ .../src/commands/config/push/SIDE_EFFECTS.md | 2 +- .../config/push/push.auth-email-content.ts | 114 +++----- .../push/push.auth-email-content.unit.test.ts | 96 ++++++- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 29 +- .../commands/functions/deploy/SIDE_EFFECTS.md | 2 +- .../functions/download/SIDE_EFFECTS.md | 2 +- .../commands/functions/serve/SIDE_EFFECTS.md | 26 +- .../commands/migration/squash/SIDE_EFFECTS.md | 21 +- apps/cli/src/commands/start/SIDE_EFFECTS.md | 7 +- .../commands/start/services/kong.service.ts | 65 +++-- .../start/services/kong.service.unit.test.ts | 74 ++--- apps/cli/src/commands/start/start.handler.ts | 105 +++++-- .../commands/start/start.integration.test.ts | 64 +++++ apps/cli/src/commands/status/SIDE_EFFECTS.md | 15 +- apps/cli/src/commands/stop/SIDE_EFFECTS.md | 18 +- 22 files changed, 862 insertions(+), 265 deletions(-) create mode 100644 apps/cli/src/command-internal/legacy-config-validate.deep-missing-path.unit.test.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index b675cbde9e..b65c1a3cae 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -251,7 +251,7 @@ This rule is consistent with the repo-wide **Refactoring Policy** ("delete obsol ### Config validation has one home -Config validation is implemented exactly once: `src/command-internal/legacy-config-validate.ts` (`legacyValidateResolvedConfig`). Both the db/migration loader (`legacy-db-config.toml-read.ts`) and the status/stop resolver (`legacy-local-config-values.ts`) build a `LegacyConfigValidationInput` from their own pipelines and call it — do not add per-command reimplementations of these checks. When a validation branch or message changes, change it there. `legacy-config-validate.parity.unit.test.ts` feeds the same broken configs through both real pipelines and asserts identical error strings; extend it when adding a branch both callers share. +Config validation is implemented exactly once: `src/command-internal/legacy-config-validate.ts` (`legacyValidateResolvedConfig`). Both the db/migration loader (`legacy-db-config.toml-read.ts`) and the status/stop resolver (`legacy-local-config-values.ts`) build a `LegacyConfigValidationInput` from their own pipelines and call it — do not add per-command reimplementations of these checks. When a validation branch or message changes, change it there. `legacy-config-validate.parity.unit.test.ts` feeds the same broken configs through both real pipelines and asserts identical error strings; extend it when adding a branch both callers share. `content_path` project-root containment (absolute paths, `..` escapes, and in-root symlinks pointing outside all rejected) is part of this same home, enforced inside `legacyResolveEmailTemplateContentPath` — any new consumer of `content_path` resolution gets containment for free and must not re-derive it locally. --- diff --git a/apps/cli/src/command-internal/legacy-config-validate.deep-missing-path.unit.test.ts b/apps/cli/src/command-internal/legacy-config-validate.deep-missing-path.unit.test.ts new file mode 100644 index 0000000000..1cb1347584 --- /dev/null +++ b/apps/cli/src/command-internal/legacy-config-validate.deep-missing-path.unit.test.ts @@ -0,0 +1,56 @@ +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +/** + * Regression coverage for the iterative (not recursive) ancestor walk-up rewrite in + * `canonicalPathForContainment` (CLI-2339's symlink-containment hardening pass) — a security + * review found the earlier, fully-recursive walk-up blew the JS call stack around ~20,000 missing + * path components. + * + * A REAL filesystem cannot actually construct a path this deep: every real syscall + * (`realpathSync`/`lstatSync`) enforces the OS's own `PATH_MAX` (~1024 bytes on macOS, ~4096 on + * Linux), which caps a real missing-component chain at a few hundred to low thousands of + * components — nowhere near the 5,000 needed to meaningfully exercise (and rule out a stack-depth + * regression in) the walk-up loop itself. This file mocks `node:fs` at the filesystem boundary + * instead — the sanctioned seam for this kind of test per this workspace's testing conventions — + * so the loop's OWN iteration count is what's under test, not the host OS's path-length limit. + * Isolated into its own file (rather than folded into `legacy-config-validate.unit.test.ts`) + * because `vi.mock` is file-scoped: every other test in that file relies on a REAL filesystem + * (real symlinks, real dangling/looping targets), which a module-wide `node:fs` mock would break. + */ +vi.mock("node:fs", () => ({ + realpathSync: vi.fn((path: string) => { + if (path === FAKE_EXISTING_BASE) return FAKE_EXISTING_BASE; + throw Object.assign(new Error(`ENOENT: no such file or directory, realpath '${path}'`), { + code: "ENOENT", + }); + }), + lstatSync: vi.fn(() => undefined), + readlinkSync: vi.fn(() => { + throw new Error("readlinkSync should never be reached — no path in this fixture is a symlink"); + }), + statSync: vi.fn(() => { + throw new Error("statSync should never be reached by the template content_path branch"); + }), +})); + +const FAKE_EXISTING_BASE = "/fake/project-root"; + +describe("canonicalPathForContainment (via legacyResolveEmailTemplateContentPath)", () => { + it("resolves a 5,000-component-deep missing content_path without blowing the call stack", async () => { + const { legacyResolveEmailTemplateContentPath } = await import("./legacy-config-validate.ts"); + + const missingSegments = Array.from({ length: 5000 }, (_, i) => `missing-${i}`); + const contentPath = `${missingSegments.join("/")}/invite.html`; + + const resolved = legacyResolveEmailTemplateContentPath({ + section: "template", + name: "invite", + contentPath, + contentPresent: false, + base: FAKE_EXISTING_BASE, + }); + + expect(resolved).toBe(join(FAKE_EXISTING_BASE, contentPath)); + }); +}); diff --git a/apps/cli/src/command-internal/legacy-config-validate.parity.unit.test.ts b/apps/cli/src/command-internal/legacy-config-validate.parity.unit.test.ts index 465f2f17bf..749d2066f6 100644 --- a/apps/cli/src/command-internal/legacy-config-validate.parity.unit.test.ts +++ b/apps/cli/src/command-internal/legacy-config-validate.parity.unit.test.ts @@ -96,6 +96,24 @@ const scenarios: ReadonlyArray = [ }, message: "Invalid config for auth.email.notification.password_changed.content_path", }, + { + name: "auth.email.notification content_path resolves outside the project root", + toml: [ + "[auth.email.notification.password_changed]", + "enabled = true", + 'content_path = "/etc/hosts"', + ], + overrides: { + auth: { + email: { + notification: { + password_changed: { enabled: true, content_path: "/etc/hosts" }, + }, + }, + }, + }, + message: "resolves outside the project root", + }, { name: "db.port = 0", toml: ["[db]", "port = 0"], diff --git a/apps/cli/src/command-internal/legacy-config-validate.ts b/apps/cli/src/command-internal/legacy-config-validate.ts index 2d6ddeeb93..dad95077cc 100644 --- a/apps/cli/src/command-internal/legacy-config-validate.ts +++ b/apps/cli/src/command-internal/legacy-config-validate.ts @@ -1,5 +1,5 @@ -import { statSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { lstatSync, readlinkSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { actionability, @@ -733,13 +733,113 @@ export function legacySigningKeysDecodeErrorMessage(cause: unknown): string { // ── email template / notification ── +/** + * Whether `candidatePath` resolves inside (or exactly to) `root`. Both + * arguments must already be canonicalized (see `canonicalPathForContainment`). + * Only rejects a genuine `..` traversal — a same-level sibling whose name + * happens to start with two dots (e.g. `..templates`) is a distinct, + * in-root path and must not be rejected. + */ +function isPathContainedInRoot(root: string, candidatePath: string): boolean { + const rel = relative(root, candidatePath); + return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)); +} + +// `readlinkSync` bypasses the OS's own `ELOOP` symlink-cycle detection when +// manually following a dangling/unsearchable/looping symlink one hop at a +// time (see `canonicalizeExistingPath` below), so that manual follow needs +// its own explicit bound. +const MAX_SYMLINK_FOLLOW_DEPTH = 40; + +/** + * Canonicalizes `path` when it exists, or returns `undefined` when it + * genuinely doesn't — the signal {@link canonicalPathForContainment} needs + * to decide whether to keep walking up towards an existing ancestor. + * + * "Exists" is decided with `lstatSync` (which doesn't itself dereference + * `path`), not by whether `realpathSync` succeeded — a dangling symlink, a + * symlink whose target directory is unsearchable (`EACCES`), or a symlink + * loop (`ELOOP`) all make `realpathSync` throw even though `path` genuinely + * exists on disk. Such a symlink is followed one hop by hand + * (`readlinkSync`) and its target canonicalized in turn, so the containment + * check always sees where the symlink actually points rather than a lexical + * guess that ignores it. Anything else existing-but-uncanonicalizable (or a + * symlink chain past {@link MAX_SYMLINK_FOLLOW_DEPTH}) is returned as-is — + * refusing to vouch for it lexically; the containment check still compares + * it honestly, and any subsequent read fails with its own real error. + */ +function canonicalizeExistingPath(path: string, depth: number): string | undefined { + try { + return realpathSync(path); + } catch { + const entry = lstatSync(path, { throwIfNoEntry: false }); + if (entry === undefined) return undefined; + if (entry.isSymbolicLink() && depth < MAX_SYMLINK_FOLLOW_DEPTH) { + const target = readlinkSync(path); + return canonicalPathForContainment( + isAbsolute(target) ? target : join(dirname(path), target), + depth + 1, + ); + } + return path; + } +} + +/** + * Canonicalizes `path` for the containment check, tolerating a path (or an + * ancestor of it) that genuinely doesn't exist yet — that's the normal case + * for a missing template file, which should surface as a missing-file + * error, not a containment error. Walks up to the deepest EXISTING + * ancestor, resolves that with `realpathSync` (dereferencing any symlinks + * in it — including a symlinked project root itself, e.g. macOS's `/tmp` + * -> `/private/tmp`), then re-appends the missing tail lexically. The + * walk-up is an iterative loop, not recursion, so it stays correct against + * a pathologically long chain of missing ancestors (a stack-depth overflow + * was observed around 20,000 components with a naive recursive walk); each + * ancestor is still checked via {@link canonicalizeExistingPath}, so an + * intermediate dangling/unsearchable/looping symlink is followed rather + * than lexically skipped over as if it were an ordinary missing directory. + * + * A dangling, unsearchable, or looping symlink is never laundered as a + * missing tail component — see {@link canonicalizeExistingPath} for how + * "exists but can't canonicalize" is told apart from "doesn't exist" and + * followed to its real target. Recurses (bounded by `depth`) only to follow + * that kind of symlink; the ancestor walk-up itself is iterative. + */ +function canonicalPathForContainment(path: string, depth = 0): string { + const canonical = canonicalizeExistingPath(path, depth); + if (canonical !== undefined) return canonical; + + const tail: string[] = [basename(path)]; + let current = dirname(path); + for (;;) { + const ancestorCanonical = canonicalizeExistingPath(current, depth); + if (ancestorCanonical !== undefined) { + return tail.reduceRight((acc, name) => join(acc, name), ancestorCanonical); + } + const parent = dirname(current); + if (parent === current) { + return resolve(tail.reduceRight((acc, name) => join(acc, name), current)); + } + tail.push(basename(current)); + current = parent; + } +} + /** * Pure exclusivity decision + path to read for one template/notification entry. Throws * {@link LegacyConfigValidateError} with the exclusivity message when `contentPath === ""` and - * `contentPresent`. Returns the absolute path to read, or `undefined` when there's nothing to - * read (both `contentPath` and `content` absent — skip, not an error). `contentPath` set (even - * when `content` is ALSO set) always wins — "both set" is not rejected, `content_path` - * silently wins/overwrites. + * `contentPresent`. Returns the absolute, canonicalized (symlink-dereferenced) path to read, or + * `undefined` when there's nothing to read (both `contentPath` and `content` absent — skip, not + * an error). `contentPath` set (even when `content` is ALSO set) always wins — "both set" is not + * rejected, `content_path` silently wins/overwrites. + * + * The resolved candidate and `base` are both canonicalized (`canonicalPathForContainment`) and + * the candidate must resolve inside `base` (`isPathContainedInRoot`) — an absolute path, a `..` + * escape, or an in-root symlink pointing outside the project root all throw, since every caller + * reads or uploads the returned path's bytes. This applies unconditionally to every caller of + * this function (config validation, `config push` content loading, `start`'s eager pre-Docker + * containment pass) — there is no flag or opt-out. * * `base` is the caller-resolved project root for both templates and notifications. */ @@ -760,10 +860,28 @@ export function legacyResolveEmailTemplateContentPath(args: { } return undefined; } - if (args.section === "notification") { - return legacyResolveNotificationContentPath(args.base, args.contentPath); + const candidate = + args.section === "notification" + ? legacyResolveNotificationContentPath(args.base, args.contentPath) + : isAbsolute(args.contentPath) + ? args.contentPath + : join(args.base, args.contentPath); + const resolvedCanonical = canonicalPathForContainment(candidate); + const canonicalBase = canonicalPathForContainment(args.base); + if (!isPathContainedInRoot(canonicalBase, resolvedCanonical)) { + // Echo the DECLARED value (`args.contentPath`), not `resolvedCanonical` — + // the declared value is either what's literally in config.toml or an + // env-var override the caller already resolved, both already known to + // the user; the fully symlink-dereferenced canonical target is not, and + // echoing it back would hand a hostile config a way to probe what an + // in-root symlink resolves to on the runner (weak recon, but needless). + throw new LegacyConfigValidateError( + `Invalid config for auth.email.${args.section}.${args.name}.content_path: ` + + `"${args.contentPath}" resolves outside the project root ${args.base} — ` + + `move the file inside the project, or use a relative path that stays inside it.`, + ); } - return isAbsolute(args.contentPath) ? args.contentPath : join(args.base, args.contentPath); + return resolvedCanonical; } /** diff --git a/apps/cli/src/command-internal/legacy-config-validate.unit.test.ts b/apps/cli/src/command-internal/legacy-config-validate.unit.test.ts index f613da095c..f9df11a8b1 100644 --- a/apps/cli/src/command-internal/legacy-config-validate.unit.test.ts +++ b/apps/cli/src/command-internal/legacy-config-validate.unit.test.ts @@ -1,4 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { LEGACY_BUCKET_NAME_PATTERN, @@ -6,9 +18,11 @@ import { LEGACY_FUNCTION_SLUG_PATTERN, LEGACY_HOOK_SECRET_PATTERN, LEGACY_PROJECT_REF_PATTERN, + LegacyConfigValidateError, type LegacyAuthInput, type LegacyConfigValidationInput, legacyParseGoBool, + legacyResolveEmailTemplateContentPath, legacyValidateResolvedConfig, } from "./legacy-config-validate.ts"; @@ -96,6 +110,246 @@ describe("LEGACY_CLERK_DOMAIN_PATTERN", () => { }); }); +// Direct coverage for the containment behavior CLI-2339 centralized into this function — every +// caller (`config push`'s `legacyLoadAuthEmailContent`, `legacy-db-config.toml-read.ts`, +// `legacy-local-config-values.ts`, `start.handler.ts`'s eager pre-Docker pass) now shares it, so +// pinning it here directly is cheaper than re-deriving it through every caller's own fixtures. +// `push.auth-email-content.unit.test.ts` keeps its own equivalent coverage through +// `legacyLoadAuthEmailContent` (CLI-2320's original suite, still exercising the same behavior +// through a real caller); this block is the new, function-level home CLI-2339 introduces. +describe("legacyResolveEmailTemplateContentPath", () => { + let projectRoot = ""; + let outsideDir = ""; + + afterEach(() => { + if (projectRoot.length > 0) { + rmSync(projectRoot, { recursive: true, force: true }); + projectRoot = ""; + } + if (outsideDir.length > 0) { + rmSync(outsideDir, { recursive: true, force: true }); + outsideDir = ""; + } + }); + + function setup(): string { + projectRoot = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-")); + return projectRoot; + } + + /** A real file outside `base`, so a containment test proves the escape check fires rather than a missing-file error. */ + function setupOutsideFile(): string { + outsideDir = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-outside-")); + const outsideFile = join(outsideDir, "secret.html"); + writeFileSync(outsideFile, "

Outside

"); + return outsideFile; + } + + function resolveContentPath( + section: "template" | "notification", + contentPath: string, + base: string, + ) { + return legacyResolveEmailTemplateContentPath({ + section, + name: "invite", + contentPath, + contentPresent: false, + base, + }); + } + + it.each(["template", "notification"] as const)( + "rejects an absolute %s content_path outside the project root", + (section) => { + const base = setup(); + const outsideFile = setupOutsideFile(); + + expect(() => resolveContentPath(section, outsideFile, base)).toThrow( + LegacyConfigValidateError, + ); + expect(() => resolveContentPath(section, outsideFile, base)).toThrow( + /resolves outside the project root/, + ); + }, + ); + + it.each(["template", "notification"] as const)( + "rejects a relative %s content_path that escapes the project root via ..", + (section) => { + const base = setup(); + const outsideFile = setupOutsideFile(); + const escapePath = relative(base, outsideFile); + + expect(() => resolveContentPath(section, escapePath, base)).toThrow( + /resolves outside the project root/, + ); + }, + ); + + it.each(["template", "notification"] as const)( + "rejects a %s content_path that is an in-root symlink pointing outside the project root", + (section) => { + const base = setup(); + const outsideFile = setupOutsideFile(); + const symlinkPath = join(base, "evil.html"); + symlinkSync(outsideFile, symlinkPath); + + expect(() => resolveContentPath(section, "./evil.html", base)).toThrow( + /resolves outside the project root/, + ); + }, + ); + + it("accepts an in-root sibling path whose name literally starts with two dots, distinct from a .. escape", () => { + // This is the exact boundary `isPathContainedInRoot`'s `rel !== ".." && + // !rel.startsWith(".." + sep)` check exists to draw: `..templates` is a real, distinct + // directory name one level under the root — not a `..` parent-traversal segment — and must + // resolve normally. + const base = setup(); + const dotDir = join(base, "..templates"); + mkdirSync(dotDir, { recursive: true }); + writeFileSync(join(dotDir, "invite.html"), "

Invite

"); + + const resolved = resolveContentPath("template", "..templates/invite.html", base); + + expect(resolved).toBe(join(realpathSync(base), "..templates", "invite.html")); + }); + + it("accepts a content_path that resolves to exactly the project root", () => { + const base = setup(); + + const resolved = resolveContentPath("template", ".", base); + + expect(resolved).toBe(realpathSync(base)); + }); + + it("resolves a missing in-root file behind a symlinked project root instead of raising the containment error", () => { + // The CLI-2339 fix: `canonicalPathForContainment` walks up to the deepest EXISTING + // ancestor and canonicalizes THAT, then lexically re-appends the missing leaf — so a + // project root reached through a symlink (`symlinkedRoot` here) still canonicalizes to the + // same base as the candidate. CLI-2320's original `realOrLexicalPath` fell back to a fully + // LEXICAL `resolve(candidate)` the moment the leaf was missing, while `root` itself was + // always realpath'd unconditionally — comparing a resolved root against an unresolved + // candidate through the symlink would have reported a false "resolves outside the project + // root" for this exact case. + const realDir = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-real-")); + const linkContainer = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-link-")); + const symlinkedRoot = join(linkContainer, "project-root"); + symlinkSync(realDir, symlinkedRoot, "dir"); + + try { + const resolved = resolveContentPath("template", "missing-invite.html", symlinkedRoot); + expect(resolved).toBe(join(realpathSync(symlinkedRoot), "missing-invite.html")); + } finally { + rmSync(linkContainer, { recursive: true, force: true }); + rmSync(realDir, { recursive: true, force: true }); + } + }); + + // Direct regression coverage for the CLI-2339 follow-up fix: `canonicalPathForContainment` + // now tells apart "this path component genuinely doesn't exist yet" from "this path exists but + // couldn't be canonicalized" (a dangling symlink, an EACCES-blocked target, or a symlink loop). + // Before this fix, ALL THREE were wrongly treated as "doesn't exist" — meaning a dangling/broken + // in-root symlink pointing outside the project root was silently ACCEPTED as in-root instead of + // rejected (verified exploitable via `start`'s Kong `rw` Docker bind mount). Only the dangling + // case above is deterministic on every OS/CI environment without special permissions; the other + // two are covered per their own comments below. + it("rejects a content_path that is an in-root dangling symlink pointing to a nonexistent target outside the project root", () => { + // The core regression case: `lstatSync` shows the symlink itself genuinely exists, but its + // target does not — before the fix, that combination was wrongly folded into "doesn't exist" + // (the same bucket as a plain missing file), silently laundering the escape as an ordinary + // missing-file resolution instead of rejecting it. + const base = setup(); + outsideDir = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-outside-")); + const neverCreatedOutsideTarget = join(outsideDir, "never-created.html"); + const danglingSymlinkPath = join(base, "dangling.html"); + symlinkSync(neverCreatedOutsideTarget, danglingSymlinkPath); + + expect(() => resolveContentPath("template", "./dangling.html", base)).toThrow( + LegacyConfigValidateError, + ); + expect(() => resolveContentPath("template", "./dangling.html", base)).toThrow( + /resolves outside the project root/, + ); + }); + + it("rejects a content_path that is an in-root symlink whose outside target sits behind an unsearchable (EACCES) directory", () => { + // A target one level inside a chmod-000 directory makes BOTH `realpathSync` and (per + // POSIX pathname resolution, since finding the target's own dirent also needs search + // permission on its parent) `lstatSync` fail with EACCES, not ENOENT — this must never be + // laundered into "doesn't exist" either. This still fails closed (never returns a path + // silently treated as in-root) in every environment this was verified against, including as + // an unprivileged, non-root user (the only case that actually exercises the EACCES branch — + // as root, chmod 000 is a no-op and the target resolves normally, hitting the ordinary + // out-of-root rejection instead). Skip only if this environment doesn't enforce the + // permission at all (e.g. running as root) — the deterministic dangling-symlink case above + // already covers the core regression without needing any permission trick. + const base = setup(); + outsideDir = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-outside-")); + const unsearchableDir = join(outsideDir, "locked"); + mkdirSync(unsearchableDir); + const target = join(unsearchableDir, "secret.html"); + writeFileSync(target, "

Locked

"); + chmodSync(unsearchableDir, 0o000); + + try { + let permissionEnforced = true; + try { + readdirSync(unsearchableDir); + permissionEnforced = false; + } catch { + // expected in a normal, unprivileged environment — confirms chmod 000 actually blocks access here. + } + if (!permissionEnforced) { + return; + } + + const symlinkPath = join(base, "unsearchable.html"); + symlinkSync(target, symlinkPath); + + // Never silently accepted as in-root: it must fail closed, one way or another. + expect(() => resolveContentPath("template", "./unsearchable.html", base)).toThrow(); + } finally { + chmodSync(unsearchableDir, 0o755); + } + }); + + it("rejects an in-root symlink loop instead of hanging or crashing", () => { + // `canonicalPathForContainment` cannot canonicalize a genuine cycle at all — past + // `MAX_SYMLINK_FOLLOW_DEPTH` hops it gives up and returns the (lexical, never + // realpath-dereferenced) path as-is, per its own contract. Containment then compares that + // unverified lexical path against the fully-canonicalized project root. A project root + // reached through no symlink of its own could coincidentally still compare equal (since + // there's nothing to dereference), so this deliberately reuses the same symlinked-root + // fixture as the "missing leaf behind a symlinked project root" test above — guaranteeing + // a real canonicalization gap between the root and the un-canonicalizable loop path, + // deterministically on every OS, rather than depending on incidental symlinks somewhere in + // the ambient tmpdir (e.g. macOS's own /tmp -> /private/tmp). + const realDir = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-real-")); + const linkContainer = mkdtempSync(join(tmpdir(), "legacy-config-validate-email-content-link-")); + const symlinkedRoot = join(linkContainer, "project-root"); + symlinkSync(realDir, symlinkedRoot, "dir"); + + try { + const loopA = join(symlinkedRoot, "loop-a.html"); + const loopB = join(symlinkedRoot, "loop-b.html"); + symlinkSync(loopB, loopA); + symlinkSync(loopA, loopB); + + expect(() => resolveContentPath("template", "./loop-a.html", symlinkedRoot)).toThrow( + LegacyConfigValidateError, + ); + expect(() => resolveContentPath("template", "./loop-a.html", symlinkedRoot)).toThrow( + /resolves outside the project root/, + ); + } finally { + rmSync(linkContainer, { recursive: true, force: true }); + rmSync(realDir, { recursive: true, force: true }); + } + }); +}); + /** * A trivially-passing full input. Every test below spreads/overrides only the field(s) its * check cares about, matching the fixture-building style of `legacy-local-config-values.unit. diff --git a/apps/cli/src/command-internal/legacy-local-config-values.ts b/apps/cli/src/command-internal/legacy-local-config-values.ts index 1a11189848..2dab9d896b 100644 --- a/apps/cli/src/command-internal/legacy-local-config-values.ts +++ b/apps/cli/src/command-internal/legacy-local-config-values.ts @@ -1064,7 +1064,7 @@ export type LegacyResolvedAuthEmail = Omit< * `auth.captcha`/`auth.passkey`/`auth.webauthn`/`auth.email.smtp` presence gaps elsewhere in this * file. An env override always wins outright when set, regardless of the raw document. * - * This resolves the SAME effective value for both `buildKongEmailTemplateMounts` and + * This resolves the SAME effective value for both `resolveKongEmailTemplateMounts` and * `resolveGotrueEnvInput` in `start.handler.ts`, which both need the post-override email config. */ export function legacyResolveAuthEmail( diff --git a/apps/cli/src/command-internal/legacy-local-config-values.unit.test.ts b/apps/cli/src/command-internal/legacy-local-config-values.unit.test.ts index 27c30daf66..3c8971f973 100644 --- a/apps/cli/src/command-internal/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/command-internal/legacy-local-config-values.unit.test.ts @@ -2670,6 +2670,23 @@ describe("legacyResolveLocalConfigValues", () => { ); }); + it("rejects an absolute template content_path outside the project root", () => { + // Proves the shared validator (`legacyResolveEmailTemplateContentPath` in + // `legacy-config-validate.ts`) now enforces project-root containment on the + // `db`/`migration`/`status`/`stop`/... shared-validation path too, not only inside + // `config push`'s own content loader — CLI-2339's centralization. + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "/etc/hosts" } } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + 'Invalid config for auth.email.template.invite.content_path: "/etc/hosts" resolves outside the project root', + ); + }); + it("resolves a relative template content_path against the workdir itself, not /supabase", () => { writeFileSync(join(tempRoot.current, "invite.html"), ""); const config = baseConfig({ diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index cd39b4a65a..c0aa17088e 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -351,7 +351,7 @@ may itself contain a `.`. - **A non-TTY script piping multiple `y`/`n` answers needs one extra leading answer for an IMPLICIT branch target.** The branch confirmation gate reads one piped stdin line just like any other prompt in this command; it runs before the per-service `keep()` prompts, so a script written for the pre-CLI-2168 prompt sequence (`api`, `db`, `auth`, ...) has every answer shifted by one when its target happens to be an inferred branch. A plain-project target, or a target named explicitly via `--project-ref`, is unaffected (no new prompt fires). - The post-run linked-project telemetry cache fill (`Effect.ensuring`, unconditional) may issue its own `GET /v1/projects/{ref}` independent of the target-detection probe above — both are best-effort/non-fatal for that fill, so a branch ref 404ing there is expected and harmless. - Run from the project root (or pass `--workdir`); `config.toml` is read relative to it. -- Auth email `content_path` resolution: `[auth.email.template.*]` and `[auth.email.notification.*]` paths are relative to the discovered project root; notification paths fall back to the legacy `supabase/`-relative location when the root-resolved file is missing. Notification HTML is read only when `enabled = true`. **Every resolved path — relative (after collapsing `..`), absolute, or reached through an in-root symlink — must stay inside the project root** (CLI-2320; symlinks are dereferenced with `realpathSync` before the check, so an in-root symlink pointing outside can't bypass it); a path that resolves outside it aborts with `Invalid config for auth.email...content_path: resolves outside the project root ()`, before any file read. +- Auth email `content_path` resolution: `[auth.email.template.*]` and `[auth.email.notification.*]` paths are relative to the discovered project root; notification paths fall back to the legacy `supabase/`-relative location when the root-resolved file is missing. Notification HTML is read only when `enabled = true`. **Every resolved path — relative (after collapsing `..`), absolute, or reached through an in-root symlink — must stay inside the project root** (CLI-2320; symlinks are dereferenced with `realpathSync` before the check, so an in-root symlink pointing outside can't bypass it); a path that resolves outside it aborts with `Invalid config for auth.email...content_path: resolves outside the project root ()`, before any file read. This containment check now lives centrally in `legacyResolveEmailTemplateContentPath` (`legacy-config-validate.ts`) rather than in this command's own module (CLI-2339), and applies unconditionally to every caller of that resolver — config validation and `start`'s eager pre-Docker pass, not just `config push`. - **Only properties your file declares, and whose value differs from the project, are written.** Fields the API requires together ship as a group; undeclared members of that group are sent with the project's CURRENT value, read in the same run — so they do not change. Only when the read did not return a member's current value is it sent at the config schema default, and that is always disclosed (a `[group-write]` block in the confirmation output, a `forced` entry in the JSON payload, and a summary `Note:` line) — never applied silently. - **`db.ssl_enforcement`'s presence, not its decoded default, decides the gate.** `@supabase/config`'s projection recovers whether `[db.ssl_enforcement]` (and `storage.image_transformation`/`storage.s3_protocol`) were actually declared, as opposed to decoding to a schema default; an undeclared `[db.ssl_enforcement]` is treated as `disabled` — no read is needed for this any more, since row 1's single response already carries the remote value. - Optional `*pointer` sections (`db.ssl_enforcement`, `storage.image_transformation`, `storage.s3_protocol`) follow that same presence rule end to end — declared-but-absent is never confused with explicitly-disabled. diff --git a/apps/cli/src/commands/config/push/push.auth-email-content.ts b/apps/cli/src/commands/config/push/push.auth-email-content.ts index 9663a206cf..67cf509cc0 100644 --- a/apps/cli/src/commands/config/push/push.auth-email-content.ts +++ b/apps/cli/src/commands/config/push/push.auth-email-content.ts @@ -3,16 +3,19 @@ * body. Both templates and notifications resolve relative paths from the * project root (parent of `supabase/`); notifications additionally fall back * to the legacy `supabase/`-relative location when the root-resolved file is - * missing, so configs written for older scaffolds keep working. Every - * resolved path — relative or absolute — is confined to the project root - * before it is read, since the loaded bytes are uploaded to whichever - * project the config names. + * missing, so configs written for older scaffolds keep working. Containment + * — confining the resolved path to the project root before it is read, since + * the loaded bytes are uploaded to whichever project the config names — is + * enforced centrally by `legacyResolveEmailTemplateContentPath` in + * `legacy-config-validate.ts`, not locally in this module. */ import type { CliConfig } from "@supabase/config"; -import { legacyResolveNotificationContentPath } from "../../../command-internal/legacy-config-validate.ts"; -import { readFileSync, realpathSync } from "node:fs"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + legacyEmailContentPathReadErrorMessage, + legacyResolveEmailTemplateContentPath, +} from "../../../command-internal/legacy-config-validate.ts"; +import { readFileSync } from "node:fs"; type AuthEmail = CliConfig["auth"]["email"]; @@ -32,74 +35,8 @@ const EMPTY_AUTH_EMAIL_CONTENT: LegacyAuthEmailContent = { }; /** - * Whether `candidatePath` resolves inside (or exactly to) `root`. Both - * arguments must already be normalized absolute paths (see `resolve`/ - * `realpathSync`). Only rejects a genuine `..` traversal — a same-level - * sibling whose name happens to start with two dots (e.g. `..templates`) - * is a distinct, in-root path and must not be rejected. - */ -function isPathContainedInRoot(root: string, candidatePath: string): boolean { - const rel = relative(root, candidatePath); - return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)); -} - -/** - * Resolves `path` to its real, symlink-free location for the containment - * check, falling back to lexical normalization when the target doesn't - * exist yet — that case has no symlink to dereference, and is left for - * `readTemplateContent` to report as a normal missing-file error. - */ -function realOrLexicalPath(path: string): string { - try { - return realpathSync(path); - } catch { - return resolve(path); - } -} - -/** - * Resolves a template/notification `content_path`, rejecting any result - * that escapes the project root — a relative `..` traversal, or an absolute - * or symlinked path pointing elsewhere on disk. Rejecting here means an - * out-of-root path is never read, since the caller only reads a path this - * function returns. Symlinks are dereferenced (`realpathSync`) before the - * containment check, since `readFileSync` would otherwise follow an - * in-root symlink straight to an out-of-root target. - * - * @param kind - `template` or `notification` (used in the error prefix and to - * select the notification-only legacy `supabase/`-relative fallback). - * @param name - Config key (e.g. `invite`, `password_changed`). - * @param cwd - Discovered project root (parent of `supabase/`). - * @param contentPath - Raw `content_path` value from the config. - * @returns Absolute, symlink-resolved path, confined to `cwd`. - * @throws When the resolved path falls outside the project root. - */ -function resolveContainedContentPath( - kind: "template" | "notification", - name: string, - cwd: string, - contentPath: string, -): string { - const candidate = - kind === "notification" - ? legacyResolveNotificationContentPath(cwd, contentPath) - : isAbsolute(contentPath) - ? contentPath - : join(cwd, contentPath); - const root = realpathSync(cwd); - const resolved = realOrLexicalPath(candidate); - if (!isPathContainedInRoot(root, resolved)) { - throw new Error( - `Invalid config for auth.email.${kind}.${name}.content_path: resolves outside the project root (${resolved})`, - ); - } - return resolved; -} - -/** - * Reads a template HTML file, wrapping a filesystem error with an - * `Invalid config for auth.email...content_path: ` - * message — the CLI's established config-validation error shape. + * Reads a template HTML file, wrapping a filesystem error with the CLI's + * established config-validation error shape. * * @param kind - `template` or `notification` (used in the error prefix). * @param name - Config key (e.g. `invite`, `password_changed`). @@ -115,8 +52,7 @@ function readTemplateContent( try { return readFileSync(resolvedPath, "utf8"); } catch (cause) { - const message = cause instanceof Error ? cause.message : String(cause); - throw new Error(`Invalid config for auth.email.${kind}.${name}.content_path: ${message}`); + throw new Error(legacyEmailContentPathReadErrorMessage(kind, name, cause)); } } @@ -141,7 +77,17 @@ export function legacyLoadAuthEmailContent(cwd: string, email: AuthEmail): Legac if (contentPath.length === 0) { continue; } - const resolved = resolveContainedContentPath("template", name, cwd, contentPath); + const resolved = legacyResolveEmailTemplateContentPath({ + section: "template", + name, + contentPath, + // Already checked contentPath.length > 0 above, so this can never fire. + contentPresent: false, + base: cwd, + }); + if (resolved === undefined) { + continue; + } template[name] = readTemplateContent("template", name, resolved); } @@ -153,7 +99,17 @@ export function legacyLoadAuthEmailContent(cwd: string, email: AuthEmail): Legac if (contentPath.length === 0) { continue; } - const resolved = resolveContainedContentPath("notification", name, cwd, contentPath); + const resolved = legacyResolveEmailTemplateContentPath({ + section: "notification", + name, + contentPath, + // Already checked contentPath.length > 0 above, so this can never fire. + contentPresent: false, + base: cwd, + }); + if (resolved === undefined) { + continue; + } notification[name] = readTemplateContent("notification", name, resolved); } diff --git a/apps/cli/src/commands/config/push/push.auth-email-content.unit.test.ts b/apps/cli/src/commands/config/push/push.auth-email-content.unit.test.ts index 499a736f74..5909ca78d3 100644 --- a/apps/cli/src/commands/config/push/push.auth-email-content.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.auth-email-content.unit.test.ts @@ -9,6 +9,19 @@ import { afterEach, describe, expect, it } from "vitest"; import { legacyLoadAuthEmailContent } from "./push.auth-email-content.ts"; +/** + * Builds the exact anchored containment-rejection regex for a given declared `content_path` — + * the thrown message echoes that DECLARED value (quoted) between the field name and "resolves + * outside the project root", not the fully-canonicalized target (a deliberate recon-leak + * mitigation — see `legacyResolveEmailTemplateContentPath`'s own doc comment). + */ +function containmentRejectionPattern(fieldPath: string, declaredContentPath: string): RegExp { + const escaped = declaredContentPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp( + `^Invalid config for ${fieldPath}: "${escaped}" resolves outside the project root`, + ); +} + const emptyEmail = { enable_signup: true, double_confirm_changes: true, @@ -180,7 +193,8 @@ describe("legacyLoadAuthEmailContent", () => { it("throws a descriptive error when a template file is missing", () => { const { cwd } = setup(); - expect(() => + let thrown: unknown; + try { legacyLoadAuthEmailContent(cwd, { ...emptyEmail, template: { @@ -189,8 +203,56 @@ describe("legacyLoadAuthEmailContent", () => { content_path: "./templates/missing.html", }, }, - }), - ).toThrow(/^Invalid config for auth\.email\.template\.invite\.content_path:/); + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + const message = (thrown as Error).message; + // A genuinely missing in-root file must surface the normal read-failure message, never the + // containment message — locks in that the symlinked-ancestor containment fix (see the + // dedicated symlink test below) doesn't regress into over-rejecting a legitimate missing + // file as "outside the project root". + expect(message).not.toMatch(/resolves outside the project root/); + expect(message).toMatch(/^Invalid config for auth\.email\.template\.invite\.content_path:/); + }); + + it("does not raise the containment error for a template file missing behind a symlinked project root", () => { + // The project root itself is reached through a symlink (mirroring macOS's `/tmp` -> + // `/private/tmp`), and the configured template file doesn't exist. Before the CLI-2339 fix + // to `canonicalPathForContainment`, comparing a realpath'd root against a lexically-resolved + // (symlink-unaware) candidate would have misreported this as escaping the project root + // instead of a plain missing file. + const realDir = mkdtempSync(join(tmpdir(), "auth-email-content-real-")); + const linkContainer = mkdtempSync(join(tmpdir(), "auth-email-content-link-")); + const symlinkedRoot = join(linkContainer, "project-root"); + symlinkSync(realDir, symlinkedRoot, "dir"); + + try { + let thrown: unknown; + try { + legacyLoadAuthEmailContent(symlinkedRoot, { + ...emptyEmail, + template: { + invite: { + subject: "You are invited", + content_path: "./missing-invite.html", + }, + }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + const message = (thrown as Error).message; + expect(message).not.toMatch(/resolves outside the project root/); + expect(message).toMatch(/^Invalid config for auth\.email\.template\.invite\.content_path:/); + } finally { + rmSync(linkContainer, { recursive: true, force: true }); + rmSync(realDir, { recursive: true, force: true }); + } }); it("rejects an absolute template content_path outside the project root", () => { @@ -207,9 +269,7 @@ describe("legacyLoadAuthEmailContent", () => { }, }, }), - ).toThrow( - /^Invalid config for auth\.email\.template\.invite\.content_path: resolves outside the project root/, - ); + ).toThrow(containmentRejectionPattern("auth.email.template.invite.content_path", outsideFile)); }); it("rejects an absolute notification content_path outside the project root", () => { @@ -228,7 +288,10 @@ describe("legacyLoadAuthEmailContent", () => { }, }), ).toThrow( - /^Invalid config for auth\.email\.notification\.password_changed\.content_path: resolves outside the project root/, + containmentRejectionPattern( + "auth.email.notification.password_changed.content_path", + outsideFile, + ), ); }); @@ -247,9 +310,7 @@ describe("legacyLoadAuthEmailContent", () => { }, }, }), - ).toThrow( - /^Invalid config for auth\.email\.template\.invite\.content_path: resolves outside the project root/, - ); + ).toThrow(containmentRejectionPattern("auth.email.template.invite.content_path", escapePath)); }); it("rejects a relative notification content_path that escapes the project root via ..", () => { @@ -269,7 +330,10 @@ describe("legacyLoadAuthEmailContent", () => { }, }), ).toThrow( - /^Invalid config for auth\.email\.notification\.password_changed\.content_path: resolves outside the project root/, + containmentRejectionPattern( + "auth.email.notification.password_changed.content_path", + escapePath, + ), ); }); @@ -290,7 +354,10 @@ describe("legacyLoadAuthEmailContent", () => { }, }), ).toThrow( - /^Invalid config for auth\.email\.template\.invite\.content_path: resolves outside the project root/, + containmentRejectionPattern( + "auth.email.template.invite.content_path", + "./evil-template.html", + ), ); }); @@ -312,7 +379,10 @@ describe("legacyLoadAuthEmailContent", () => { }, }), ).toThrow( - /^Invalid config for auth\.email\.notification\.password_changed\.content_path: resolves outside the project root/, + containmentRejectionPattern( + "auth.email.notification.password_changed.content_path", + "./evil-notification.html", + ), ); }); diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 43573606ac..1a62b2b4ff 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -19,20 +19,21 @@ it, and JSON `null` disables formatting without disabling safe compaction. ## Files Read -| Path | Format | When | -| --------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | -| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (all native targets, and the explicit `--from/--to migrations` cache miss) | -| `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | -| `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) — `--use-pgadmin` too, via the SAME `legacyMigrateShadowDatabase` | -| `/supabase/roles.sql` | SQL | shadow provisioning, PG14 and PG15 alike (unlike `db reset`'s PG15-only local path); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | -| `[db.migrations].schema_paths` globs / `/supabase/database/**` / `/supabase/schemas/**` | SQL | legacy engines only, for the local-target declarative-schema fallback; pg-delta next always compares the migrations baseline directly to the live target | -| `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | -| `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `/supabase/.temp/{pgdelta-version,edge-runtime-version}` | plain text | legacy pg-delta opt-out only | -| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out's explicit `--from/--to migrations` catalog cache | +| Path | Format | When | +| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (all native targets, and the explicit `--from/--to migrations` cache miss) | +| `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | +| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | only when `auth.enabled`, for every configured template and every notification with `enabled = true` — via the same `legacyReadDbToml`/`legacyCheckDbToml` `Config.Validate` pipeline shared by every `db`/`migration` subcommand that loads config (`db dump`/`pull`/`reset`/`push`/`schema declarative generate`/`sync`, `migration up`/`down`/`squash` — documented once here rather than duplicated per file, CLI-2339); the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | +| `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) — `--use-pgadmin` too, via the SAME `legacyMigrateShadowDatabase` | +| `/supabase/roles.sql` | SQL | shadow provisioning, PG14 and PG15 alike (unlike `db reset`'s PG15-only local path); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | +| `[db.migrations].schema_paths` globs / `/supabase/database/**` / `/supabase/schemas/**` | SQL | legacy engines only, for the local-target declarative-schema fallback; pg-delta next always compares the migrations baseline directly to the live target | +| `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | +| `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `/supabase/.temp/{pgdelta-version,edge-runtime-version}` | plain text | legacy pg-delta opt-out only | +| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out's explicit `--from/--to migrations` catalog cache | ## Files Written diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 2520568151..c39232d2a9 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -7,7 +7,7 @@ | `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | | `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | project dotenv (`legacyResolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | | `/supabase/config.toml` | TOML | to resolve function config, project id, and local Functions — via `goConfigCompat`'s `tomlOnly: true`/`search: false` (same resolver `start`/`stop`/`status` use), so `config.json` is never read here and no ancestor directory is searched past ``; also runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`), so an invalid config fails up front even for fields this command never otherwise reads | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | as part of the `Config.Validate` pipeline above, unconditionally | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | as part of the `Config.Validate` pipeline above, unconditionally; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | | `/supabase/functions//index.ts` | TypeScript | function source to deploy | | `/supabase/functions/**/deno.json*` | JSON/JSONC | when resolving import maps | | imported modules | TypeScript | when walking local import graphs for deploy uploads/bundles | diff --git a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md index 79e594625c..460aa3b8a3 100644 --- a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md @@ -11,7 +11,7 @@ | `/supabase/.temp/edge-runtime-version` | plain text | Read unconditionally by `resolveEdgeRuntimeVersionPin()` in the handler, before the shared downloader chooses `--use-api` vs Docker — only affects the resolved edge-runtime image tag on the Docker-unbundle path | | `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | Docker-unbundle path only, before resolving config.toml — project dotenv (`legacyResolveProjectEnvironmentValues`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | | `/supabase/config.toml` | TOML | Read unconditionally after resolving the project ref, before checking `--use-api`/`--use-docker` or whether Docker is running — resolves `edge_runtime.deno_version` and `project_id` (`loadCliConfig`) for the Docker-unbundle path. `goViperCompat`'s `tomlOnly: true` means `config.json` is never read here, unlike other `loadCliConfig` callers. A malformed config fails here even on the `--use-api` invocation. Also runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` already use) — an invalid config (bad `db.major_version`, malformed auth hook, etc.) now fails the Docker-unbundle path up front, even for fields this command never otherwise reads. | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | Docker-unbundle path only, as part of the `Config.Validate` pipeline above — read even though this command never uses their contents | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | Docker-unbundle path only, as part of the `Config.Validate` pipeline above — read even though this command never uses their contents; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | | `/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written diff --git a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md index 58f457fff3..845e0e3f9c 100644 --- a/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/serve/SIDE_EFFECTS.md @@ -2,19 +2,19 @@ ## Files Read -| Path | Format | When | -| -------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | -| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | on every startup / restart, a SECOND, independent read from the `env()`-interpolation one below — project dotenv (`legacyResolveProjectEnvironmentValues`) feeding the `SUPABASE_*` overrides (network-id, deno-version, registry) and the `Config.Validate` pipeline, same one `start`/`stop`/`status` already use | -| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | on every startup / restart, as part of the `Config.Validate` pipeline above, unconditionally — read even though `serve` doesn't otherwise use their contents | -| `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | -| `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | -| `/supabase/functions//.env` | dotenv | for each enabled Function when `--env-file` is unset; values override the shared fallback for that Function only | -| `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | -| `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | -| config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | -| `` | JSON | when `auth.signing_keys_path` is configured | -| `apps/cli/src/shared/functions/serve.main.ts` (+ `serve-main-deps.ts`) | TypeScript | only when running from source (`bun src/supabase.ts`), bundled on demand; compiled binaries embed the pre-bundled template and read nothing | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | on every startup / restart when the project config exists | +| `/supabase/{.env,.env.local,.env.,.env..local}` and the same four at the project root | dotenv | on every startup / restart, a SECOND, independent read from the `env()`-interpolation one below — project dotenv (`legacyResolveProjectEnvironmentValues`) feeding the `SUPABASE_*` overrides (network-id, deno-version, registry) and the `Config.Validate` pipeline, same one `start`/`stop`/`status` already use | +| `/supabase/`, `[api.tls]` cert/key paths, email template `content_path` — when configured | varies | on every startup / restart, as part of the `Config.Validate` pipeline above, unconditionally — read even though `serve` doesn't otherwise use their contents; a `content_path` resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) before it is read, aborting with `resolves outside the project root` before any other work | +| `/supabase/.temp/edge-runtime-version` | plain text | when present, to override the bundled edge-runtime image tag | +| `/supabase/functions/.env` | dotenv | when `--env-file` is unset and the fallback env file exists | +| `/supabase/functions//.env` | dotenv | for each enabled Function when `--env-file` is unset; values override the shared fallback for that Function only | +| `` | dotenv | when `--env-file` is set; relative paths resolve from the caller cwd | +| `/supabase/functions/*/index.ts` | TypeScript | to discover filesystem-backed functions | +| config-declared entrypoints / import maps / static files and imports | mixed | for each enabled function while resolving Docker bind mounts | +| `` | JSON | when `auth.signing_keys_path` is configured | +| `apps/cli/src/shared/functions/serve.main.ts` (+ `serve-main-deps.ts`) | TypeScript | only when running from source (`bun src/supabase.ts`), bundled on demand; compiled binaries embed the pre-bundled template and read nothing | ## Files Written diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 3a154d1c8a..b6c868dc2b 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -9,16 +9,17 @@ migration-history table to match. ## Files Read -| Path | Format | When | -| ----------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always, twice: `@supabase/config` for the shadow's own spec, `legacyReadDbToml` for shadow port/password/vault/baseline | -| `/supabase/migrations/` | directory | always | -| `/supabase/migrations/_*.sql` | SQL | each migration up to the target, applied to the shadow; the target file's own final content is read by `--version`/baseline lookups | -| `/supabase/roles.sql` | SQL | shadow `SetupDatabase` (custom-roles seed); missing file tolerated | -| `/supabase/.env`, `.env.local`, `SUPABASE_ENV`-selected dotenv | dotenv | always (`--yes`/registry/network-id overrides) | -| `/supabase/.temp/{project-ref,postgres-version,pooler-url}` | plain text | `--linked` / linked path — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `~/.supabase/access-token` | plain text | `--linked` without `--password`/`SUPABASE_ACCESS_TOKEN` | -| `~/.docker/config.json` + Docker context store | JSON | resolving the Docker hostname for shadow/pg_dump containers | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always, twice: `@supabase/config` for the shadow's own spec, `legacyReadDbToml` for shadow port/password/vault/baseline | +| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | only when `auth.enabled`, for every configured template and every notification with `enabled = true` — via the same `legacyReadDbToml` `Config.Validate` pipeline shared by `migration up`/`down` and every `db` subcommand that loads config (`dump`/`pull`/`reset`/`diff`/`push`/`schema declarative generate`/`sync` — documented on `db diff`'s `SIDE_EFFECTS.md` and here, rather than duplicated per file, CLI-2339); the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | +| `/supabase/migrations/` | directory | always | +| `/supabase/migrations/_*.sql` | SQL | each migration up to the target, applied to the shadow; the target file's own final content is read by `--version`/baseline lookups | +| `/supabase/roles.sql` | SQL | shadow `SetupDatabase` (custom-roles seed); missing file tolerated | +| `/supabase/.env`, `.env.local`, `SUPABASE_ENV`-selected dotenv | dotenv | always (`--yes`/registry/network-id overrides) | +| `/supabase/.temp/{project-ref,postgres-version,pooler-url}` | plain text | `--linked` / linked path — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `~/.supabase/access-token` | plain text | `--linked` without `--password`/`SUPABASE_ACCESS_TOKEN` | +| `~/.docker/config.json` + Docker context store | JSON | resolving the Docker hostname for shadow/pg_dump containers | ## Files Written diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index c8aa18de2d..ecf01d03c5 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -82,7 +82,7 @@ command. | project-root / `SUPABASE_ENV`-selected dotenv file | dotenv | always, same precedence chain as `stop`/`status` | | `auth.signing_keys_path` file | JSON | when configured | | `api.tls.cert_path` / `api.tls.key_path` | PEM | when `api.tls.enabled` | -| `auth.email.template.*` / `auth.email.notification.*` content files | text | when configured | +| `auth.email.template.*` / `auth.email.notification.*` content files | text | when configured — for every configured template plus every ENABLED notification, resolved paths are CONFINED to the project root (symlinks dereferenced with `realpathSync`) and read-verified (bytes discarded) in an eager pre-Docker pass, REGARDLESS of `auth.enabled` (Kong mounts these unconditionally — see Notes) | | GCP JWT credentials file | JSON | when `analytics.backend = "bigquery"` | | `/supabase/roles.sql` | SQL | on a fresh volume (custom-roles seed) — the "Seeding globals..." message always prints first; the file itself is only read if it exists, tolerating a missing file | | `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume, via the standard migration-apply + seed pipeline | @@ -202,7 +202,7 @@ code is surfaced on failure. | `0` | `--ignore-health-check` set and one or more containers timed out — the failure is printed and swallowed, no rollback | | `1` | `--ignore-health-check` set, the fresh-volume/Storage-healthy recheck-and-seed path ran (see "Storage bucket seeding"), and that seed itself failed — rolls back despite the flag | | `1` | malformed CSV in an `--exclude`/`-x` value — fails during flag parsing, before the handler and telemetry, with the exact diagnostic text on stderr; the shorthand frames it with both spellings (e.g. `invalid argument "a\"b" for "-x, --exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | -| `1` | malformed `config.toml` / `Config.Validate` failure | +| `1` | malformed `config.toml` / `Config.Validate` failure, including an `auth.email.*.content_path` that resolves outside the project root, or that resolves in-root but is missing/unreadable (checked eagerly, before any Docker work, regardless of `auth.enabled` — see Notes) | | `1` | stopped Postgres detected but the project id sanitizes to empty — aborts before recovery removes any containers | | `1` | `docker`/`podman` not spawnable, or the daemon is unreachable | | `1` | stopped-stack recovery cannot list, stop, or prune current-project containers, or prune matching networks — aborts before startup; named volumes are preserved | @@ -350,7 +350,8 @@ prose, not structured data. (nothing under its own directory anymore); it still matters for a removed Edge Runtime container, whose own env-file/multiline-env-script staging is unaffected by that change. -- Existing local values declared under a Function import map's `scopes` are mounted read-only into Edge Runtime, and into the Studio container that shares the same resolved Function bind mounts, even when they resolve outside the nearest Git root. The mounted target itself is bound as declared; imports reached from inside an out-of-root target are not additionally bound. Edge Runtime bring-up prints a `WARN` naming each distinct out-of-root host path once; Studio's bind resolution stays silent, so with Edge Runtime excluded (`-x edge-runtime`) the mounts still reach Studio and no warning is printed. Missing targets retain Edge Runtime startup's existing skip behavior. +- Existing local values declared under a Function import map's `scopes` are mounted read-only into Edge Runtime, and into the Studio container that shares the same resolved Function bind mounts, even when they resolve outside the nearest Git root. The mounted target itself is bound as declared; imports reached from inside an out-of-root target are not additionally bound. Edge Runtime bring-up prints a `WARN` naming each distinct out-of-root host path once; Studio's bind resolution stays silent, so with Edge Runtime excluded (`-x edge-runtime`) the mounts still reach Studio and no warning is printed. Missing targets retain Edge Runtime startup's existing skip behavior. This is NOT the same permissiveness as email template `content_path` below — a `scopes` value is an explicit opt-in mechanism, mounted `:ro`, with containment enforced on the upload side elsewhere (`functions deploy`), whereas `content_path` has no opt-in at all and is enforced at resolution time, mounted `:rw`. +- **Auth email `content_path` project-root containment AND readability apply here regardless of `auth.enabled`.** Kong's mount set (`resolveKongEmailTemplateMounts`, `start.handler.ts`) covers every configured `[auth.email.template.*]` entry, and every `enabled = true` `[auth.email.notification.*]` entry, unconditionally — Kong is the stack's mandatory gateway, independent of whether GoTrue itself is started. `start.handler.ts` resolves, confines (`legacyResolveEmailTemplateContentPath`; symlinks dereferenced with `realpathSync` before the check), AND read-verifies (a discarded `readFileSync`) that same set in one eager pass before any Docker work, in addition to the `auth.enabled`-gated read `legacyResolveLocalConfigValues`'s own validation performs; the resulting resolved path is threaded straight into Kong's bind mount (`legacyBuildKongEmailTemplateBind` in `kong.service.ts`, which no longer re-resolves anything) rather than re-derived later, right before Kong's own `docker create`. A path that resolves outside the project root aborts the run with `Invalid config for auth.email.
..content_path: resolves outside the project root ()`; a resolved, in-root path that cannot be read as a regular file aborts with `Invalid config for auth.email.
..content_path: ` — both before a single container is created, and both regardless of `auth.enabled` (closing the gap where a missing `content_path` would otherwise reach an unconditional Kong bind mount and the root-privileged Docker daemon would silently create a directory there). - Docker status `created` is not considered a recoverable stopped stack: the container and named volume are preserved because the volume may not have completed its first database initialization, and `start` reports the existing not-running status instead. diff --git a/apps/cli/src/commands/start/services/kong.service.ts b/apps/cli/src/commands/start/services/kong.service.ts index 8d3b72104b..a845dac3cb 100644 --- a/apps/cli/src/commands/start/services/kong.service.ts +++ b/apps/cli/src/commands/start/services/kong.service.ts @@ -50,7 +50,6 @@ */ import * as nodePath from "node:path"; -import { legacyResolveNotificationContentPath } from "../../../command-internal/legacy-config-validate.ts"; import type { LegacyStartContainerSpec } from "../../../command-internal/db-bootstrap/docker-create-args.ts"; import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; @@ -135,41 +134,44 @@ export interface LegacyKongEmailTemplateMount { * per-mount path. */ readonly id: string; - /** `tmpl.ContentPath` — empty means "not configured" (no bind emitted). */ - readonly contentPath: string; /** - * Notification mounts resolve through - * `legacyResolveNotificationContentPath` so the bind targets the same file - * config validation accepted (including the legacy `supabase/`-relative - * fallback); template mounts keep plain workdir resolution. + * Absolute HOST path, already resolved, containment-checked, AND + * read-verified by the caller (`start.handler.ts`'s + * `resolveKongEmailTemplateMounts`, via `legacyResolveEmailTemplateContentPath` + * plus a discarded `readFileSync`) — never a raw, unresolved + * `content_path`. There is no "not configured" sentinel here: the caller + * omits an entry entirely instead of including one with an empty path. + */ + readonly resolvedPath: string; + /** + * `true` for a mount derived from an ENABLED `auth.email.notification.*` + * entry (vs a `auth.email.template.*` entry) — caller-side bookkeeping + * only; this module no longer branches on it, since resolution (including + * the notification-specific legacy `supabase/`-relative fallback) already + * happened upstream, once, before `resolvedPath` was set. */ readonly notification?: boolean; } /** - * Resolves `contentPath` to an absolute HOST path (relative to the process's - * own working directory, the same project-root base used while validating - * `content_path`), joins it onto the fixed in-container email-template - * directory as `` (POSIX — the container is always - * Linux regardless of the host OS, hence `nodePath.posix.join`, not the - * platform-dependent `nodePath.join`), and formats the `rw` bind. Returns - * `undefined` for an empty `contentPath` (no bind appended). + * Formats one email-template bind mount: joins `mount.resolvedPath` onto the + * fixed in-container email-template directory as `` + * (POSIX — the container is always Linux regardless of the host OS, hence + * `nodePath.posix.join`, not the platform-dependent `nodePath.join`), and + * formats the `rw` bind. + * + * A pure formatter over an already-validated path — it makes no containment + * or existence claims of its own. `start.handler.ts` resolves, confines to + * the project root, and read-verifies every mount's `resolvedPath` exactly + * once, before any Docker work runs (see + * `LegacyKongEmailTemplateMount.resolvedPath`'s doc comment). */ -export function legacyBuildKongEmailTemplateBind( - mount: LegacyKongEmailTemplateMount, - workdir: string, -): string | undefined { - if (mount.contentPath.length === 0) return undefined; - const hostPath = mount.notification - ? legacyResolveNotificationContentPath(workdir, mount.contentPath) - : nodePath.isAbsolute(mount.contentPath) - ? mount.contentPath - : nodePath.resolve(workdir, mount.contentPath); +export function legacyBuildKongEmailTemplateBind(mount: LegacyKongEmailTemplateMount): string { const dockerPath = nodePath.posix.join( LEGACY_KONG_NGINX_EMAIL_TEMPLATE_DIR, - `${mount.id}${nodePath.extname(hostPath)}`, + `${mount.id}${nodePath.extname(mount.resolvedPath)}`, ); - return `${hostPath}:${dockerPath}:rw`; + return `${mount.resolvedPath}:${dockerPath}:rw`; } const LEGACY_KONG_ENTRYPOINT_HEAD = @@ -249,11 +251,6 @@ export interface LegacyKongContainerSpecInput { * this builder a pure function of its `input`. */ readonly nginxWorkerProcesses: string; - /** - * `LegacyCliSettings.workdir` — used to resolve any relative - * {@link emailTemplateMounts} `contentPath` to an absolute host path. - */ - readonly workdir: string; /** * Every `config.auth.email.template.*`/enabled * `config.auth.email.notification.*` entry the caller has already @@ -288,9 +285,9 @@ export function legacyBuildKongContainerSpec( queryToken: legacyBuildKongQueryToken(input.apiKeys), }); - const binds = (input.emailTemplateMounts ?? []) - .map((mount) => legacyBuildKongEmailTemplateBind(mount, input.workdir)) - .filter((bind): bind is string => bind !== undefined); + const binds = (input.emailTemplateMounts ?? []).map((mount) => + legacyBuildKongEmailTemplateBind(mount), + ); const dockerPort = input.apiTlsEnabled ? 8443 : 8000; diff --git a/apps/cli/src/commands/start/services/kong.service.unit.test.ts b/apps/cli/src/commands/start/services/kong.service.unit.test.ts index 76166a9fde..fdd94321dc 100644 --- a/apps/cli/src/commands/start/services/kong.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/kong.service.unit.test.ts @@ -1,6 +1,3 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { @@ -55,59 +52,24 @@ describe("legacyResolveKongNginxWorkerProcesses", () => { }); describe("legacyBuildKongEmailTemplateBind", () => { - test("returns undefined for an empty contentPath (start.go:528-530)", () => { + // Resolution (workdir-relative joins, the notification-specific legacy + // supabase/-relative fallback, absolute-path passthrough, containment, and + // read-verification) all moved to `start.handler.ts`'s + // `resolveKongEmailTemplateMounts` (CLI-2339's Kong-mount hardening pass) — + // see that function's own test coverage in `start.integration.test.ts` and + // `legacy-config-validate.unit.test.ts`'s `legacyResolveEmailTemplateContentPath` + // suite. This function is now a pure formatter over an already-resolved + // `mount.resolvedPath`; the remaining tests below only cover that formatting. + + test("builds the bind string from an already-resolved resolvedPath (start.go:531-538)", () => { expect( - legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "" }, "/work"), - ).toBeUndefined(); - }); - - test("resolves a relative contentPath against workdir (start.go:531-538)", () => { - expect( - legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "invite.html" }, "/work"), + legacyBuildKongEmailTemplateBind({ id: "invite", resolvedPath: "/work/invite.html" }), ).toBe("/work/invite.html:/home/kong/templates/email/invite.html:rw"); }); - test("notification mounts fall back to the legacy supabase-relative file", () => { - const workdir = mkdtempSync(join(tmpdir(), "kong-email-bind-")); - try { - mkdirSync(join(workdir, "supabase", "templates"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "templates", "n.html"), "

x

"); - expect( - legacyBuildKongEmailTemplateBind( - { - id: "password_changed_notification", - contentPath: "./templates/n.html", - notification: true, - }, - workdir, - ), - ).toBe( - `${join(workdir, "supabase", "templates", "n.html")}:/home/kong/templates/email/password_changed_notification.html:rw`, - ); - // template mounts keep plain workdir resolution even when the file is absent - expect( - legacyBuildKongEmailTemplateBind( - { id: "invite", contentPath: "./templates/n.html" }, - workdir, - ), - ).toBe(`${join(workdir, "templates", "n.html")}:/home/kong/templates/email/invite.html:rw`); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } - }); - - test("leaves an absolute contentPath untouched", () => { - expect( - legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "/abs/invite.html" }, "/work"), - ).toBe("/abs/invite.html:/home/kong/templates/email/invite.html:rw"); - }); - - test("drops the extension when hostPath has none", () => { + test("drops the extension when resolvedPath has none", () => { expect( - legacyBuildKongEmailTemplateBind( - { id: "invite_notification", contentPath: "invite" }, - "/work", - ), + legacyBuildKongEmailTemplateBind({ id: "invite_notification", resolvedPath: "/work/invite" }), ).toBe("/work/invite:/home/kong/templates/email/invite_notification:rw"); }); }); @@ -150,7 +112,6 @@ const base: LegacyKongContainerSpecInput = { logflareId: "supabase_analytics_proj", poolerId: "supabase_pooler_proj", nginxWorkerProcesses: "1", - workdir: "/work", }; describe("legacyBuildKongContainerSpec", () => { @@ -229,12 +190,15 @@ describe("legacyBuildKongContainerSpec", () => { }); test("mounts every resolved email template bind (start.go:544-558)", () => { + // Every entry here is already resolved+containment-checked+read-verified by the + // caller (`start.handler.ts`'s `resolveKongEmailTemplateMounts`) — there is no + // "unconfigured" entry to filter downstream anymore, since the caller omits those + // entirely before this input is ever built. const spec = legacyBuildKongContainerSpec({ ...base, emailTemplateMounts: [ - { id: "invite", contentPath: "invite.html" }, - { id: "confirmation_notification", contentPath: "" }, - { id: "recovery_notification", contentPath: "/abs/recovery.html" }, + { id: "invite", resolvedPath: "/work/invite.html" }, + { id: "recovery_notification", resolvedPath: "/abs/recovery.html" }, ], }); expect(spec.binds).toEqual([ diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index 2806c375ad..d9fb524605 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -2,6 +2,7 @@ * Native TS implementation of `start` — see `SIDE_EFFECTS.md` for the full * behavior contract. */ +import { readFileSync } from "node:fs"; import { inferFunctionsManifest } from "@supabase/config/effect"; import { resolveCliConfigSubtree } from "@supabase/config/internal"; import { Effect, FileSystem, Option, Path, Result } from "effect"; @@ -33,7 +34,9 @@ import { legacyAqua, legacyYellow } from "../../command-internal/legacy-colors.t import { legacyApiTlsCertReadErrorMessage, legacyApiTlsKeyReadErrorMessage, + legacyEmailContentPathReadErrorMessage, legacyResolveApiTlsPath, + legacyResolveEmailTemplateContentPath, } from "../../command-internal/legacy-config-validate.ts"; import { legacyIsContainerNotFoundMessage } from "../../command-internal/legacy-container-cli.ts"; import { legacyCheckDbToml } from "../../command-internal/legacy-db-config.toml-read.ts"; @@ -352,29 +355,78 @@ function resolveGotrueEnvInput(params: { }; } +/** + * Read-and-discard existence/readability check for one already-resolved + * `content_path` — same pattern as `legacy-local-config-values.ts`'s + * `readAuthEmailTemplateContent` and `push.auth-email-content.ts`'s + * `readTemplateContent`, reusing their established error message shape. + * Closes the gap where a resolved-but-never-read path (e.g. a `content_path` + * naming a missing file, only reachable when `auth.enabled = false`) would + * otherwise reach Docker unverified — the root-privileged daemon silently + * creates a directory at a bind-mounted host path that doesn't exist, so an + * unprivileged read here must succeed first. + */ +function readKongEmailTemplateContent( + section: "template" | "notification", + name: string, + resolvedPath: string, +): void { + try { + readFileSync(resolvedPath, "utf8"); + } catch (cause) { + throw new Error(legacyEmailContentPathReadErrorMessage(section, name, cause)); + } +} + /** * Kong's email template mounts: every configured template, then every - * ENABLED notification, suffixed `_notification`. + * ENABLED notification, suffixed `_notification`. Resolves, containment- + * checks, and read-verifies each `content_path` HERE — once, before any + * Docker work — via `legacyResolveEmailTemplateContentPath` (the same check + * config validation and `config push` apply) followed by + * `readKongEmailTemplateContent`. The resulting `resolvedPath` is what the + * caller threads straight into `legacyBuildKongEmailTemplateBind`; nothing + * re-derives it later, right before the `docker create` call for Kong + * (potentially minutes later, after image pulls/Postgres bring-up/ + * migrations) — closing the TOCTOU window between an earlier + * validation-only pass and Kong's own independent re-resolution. * - * Path resolution happens in the bind builder — see - * `LegacyKongEmailTemplateMount.notification`. + * Skips (never throws for) an entry whose resolver returns `undefined` — per + * its own contract that only happens for an empty/absent `content_path`, + * which should be unreachable here since Kong's set is built from configured + * entries, but this omits the mount defensively rather than crashing. */ -function buildKongEmailTemplateMounts( +function resolveKongEmailTemplateMounts( email: LegacyResolvedAuthEmail, + workdir: string, ): ReadonlyArray { - return [ - ...Object.entries(email.template).map(([id, template]) => ({ - id, + const mounts: Array = []; + for (const [id, template] of Object.entries(email.template)) { + const resolvedPath = legacyResolveEmailTemplateContentPath({ + section: "template", + name: id, contentPath: template.content_path, - })), - ...Object.entries(email.notification) - .filter(([, notification]) => notification.enabled) - .map(([id, notification]) => ({ - id: `${id}_notification`, - contentPath: notification.content_path, - notification: true, - })), - ]; + contentPresent: false, + base: workdir, + }); + if (resolvedPath === undefined) continue; + readKongEmailTemplateContent("template", id, resolvedPath); + mounts.push({ id, resolvedPath }); + } + for (const [id, notification] of Object.entries(email.notification)) { + if (!notification.enabled) continue; + const resolvedPath = legacyResolveEmailTemplateContentPath({ + section: "notification", + name: id, + contentPath: notification.content_path, + contentPresent: false, + base: workdir, + }); + if (resolvedPath === undefined) continue; + readKongEmailTemplateContent("notification", id, resolvedPath); + mounts.push({ id: `${id}_notification`, resolvedPath, notification: true }); + } + return mounts; } /** @@ -461,6 +513,24 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta message: cause instanceof Error ? cause.message : String(cause), }), }); + // Kong mounts every configured template (regardless of `auth.enabled` — + // Kong is the stack's mandatory gateway) and every ENABLED notification's + // `content_path`, unconditionally. Resolving, containment-checking, AND + // read-verifying every path happens exactly ONCE, here, before any Docker + // work — not only inside `legacyResolveLocalConfigValues`'s own + // `auth.enabled`-gated `readAuthEmailTemplateContent` call. The resulting + // `resolvedPath`s are threaded straight into the Kong container-spec + // input below instead of being discarded and re-derived later inside + // `legacyBuildKongEmailTemplateBind`, which closes the TOCTOU window + // between this pass and Kong's `docker create` call (potentially minutes + // later, after image pulls/Postgres bring-up/migrations). + const kongEmailTemplateMounts = yield* Effect.try({ + try: () => resolveKongEmailTemplateMounts(resolvedEmail, cliSettings.workdir), + catch: (cause) => + new LegacyStartInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); // Every `time.Duration`-shaped config field — including these 5 — must // fail fast, before `start` touches Docker at all: these fields are only // parsed inside GoTrue's own env builder (`gotrue.service.ts`), which @@ -1323,8 +1393,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta logflareId: logflareContainerName, poolerId: poolerContainerName, nginxWorkerProcesses: legacyResolveKongNginxWorkerProcesses(projectEnvValues), - workdir: cliSettings.workdir, - emailTemplateMounts: buildKongEmailTemplateMounts(resolvedEmail), + emailTemplateMounts: kongEmailTemplateMounts, }), }; } diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index 539035fdc1..d69dc40c28 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -1368,6 +1368,70 @@ describe("legacy start integration", () => { expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); }); + + it.live( + "rejects an out-of-root auth.email.template content_path before any Docker work, even with auth disabled", + () => { + // `auth.enabled = false` skips `legacyResolveLocalConfigValues`'s own + // `readAuthEmailTemplateContent` gate, but Kong mounts every configured template + // unconditionally (`buildKongEmailTemplateMounts`, regardless of `auth.enabled`) — the + // eager pre-Docker containment pass added to `start.handler.ts` (CLI-2339) is what closes + // that gap, resolving+checking every template/enabled-notification `content_path` before + // `create` is ever spawned. + const { layer, child } = setup({ + configContents: + 'project_id = "demo"\n[auth]\nenabled = false\n[auth.email.template.invite]\ncontent_path = "/etc/hosts"\n', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStart(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStartInvalidConfigError"); + // The thrown message echoes the DECLARED content_path value (quoted), not the + // fully-canonicalized target — see `legacyResolveEmailTemplateContentPath`'s own doc + // comment for why (a deliberate recon-leak mitigation). + expect(serialized).toContain( + 'Invalid config for auth.email.template.invite.content_path: \\"/etc/hosts\\" resolves outside the project root', + ); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "fails on a missing (but in-root) auth.email.template content_path before any Docker work, even with auth disabled", + () => { + // `auth.enabled = false` skips `legacy-local-config-values.ts`'s own gated + // `readAuthEmailTemplateContent` read entirely — this content_path resolves IN-ROOT + // (passes containment cleanly), so the only thing that can still catch a missing file + // here is the read-verification `resolveKongEmailTemplateMounts` added in `start. + // handler.ts` (CLI-2339's Kong-mount hardening pass). Without it, this would have + // reached `docker create` with a bind-mount source that doesn't exist on disk. + const { layer, workdir, child } = setup({ + configContents: + 'project_id = "demo"\n[auth]\nenabled = false\n[auth.email.template.invite]\ncontent_path = "./templates/missing.html"\n', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyStart(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const serialized = JSON.stringify(exit.cause); + expect(serialized).toContain("LegacyStartInvalidConfigError"); + expect(serialized).toContain( + "Invalid config for auth.email.template.invite.content_path:", + ); + // Distinguishes this from the containment-rejection test above: this content_path + // never escapes the project root at all, so a regression back to "no read- + // verification" would have this test's exit succeed instead of fail. + expect(serialized).not.toContain("resolves outside the project root"); + } + expect(existsSync(join(workdir, "templates", "missing.html"))).toBe(false); + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); }); describe("happy path", () => { diff --git a/apps/cli/src/commands/status/SIDE_EFFECTS.md b/apps/cli/src/commands/status/SIDE_EFFECTS.md index e34a0249ea..d56789b467 100644 --- a/apps/cli/src/commands/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/status/SIDE_EFFECTS.md @@ -8,13 +8,14 @@ which branch they linked) can discover which project/branch it's on without a se ## Files Read -| Path | Format | When | -| ------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | -| `/supabase/config.toml` | TOML | always, to resolve project configuration | -| `auth.signing_keys_path` (config-relative or absolute) | JSON | only when `auth.signing_keys_path` is set in config.toml | -| `api.tls.cert_path` / `api.tls.key_path` (unconditionally joined with `/supabase`, no absolute-path guard) | raw bytes | only when `api.enabled` and `api.tls.enabled`, and the respective path is set | -| `/supabase/.temp/project-ref` | plain text | always (soft) — the linked-state "currently linked ref" lookup (CLI-2167 follow-up, TS-only) | -| `/supabase/.temp/linked-project.json` | JSON | always (soft), once linked — determines plain-project-vs-branch state and the display name (CLI-2167 follow-up, TS-only) | +| Path | Format | When | +| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to resolve project configuration | +| `auth.signing_keys_path` (config-relative or absolute) | JSON | only when `auth.signing_keys_path` is set in config.toml | +| `api.tls.cert_path` / `api.tls.key_path` (unconditionally joined with `/supabase`, no absolute-path guard) | raw bytes | only when `api.enabled` and `api.tls.enabled`, and the respective path is set | +| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | only when `auth.enabled`, for every configured template and every notification with `enabled = true`; the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | +| `/supabase/.temp/project-ref` | plain text | always (soft) — the linked-state "currently linked ref" lookup (CLI-2167 follow-up, TS-only) | +| `/supabase/.temp/linked-project.json` | JSON | always (soft), once linked — determines plain-project-vs-branch state and the display name (CLI-2167 follow-up, TS-only) | ## Files Written diff --git a/apps/cli/src/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/stop/SIDE_EFFECTS.md index de9229f891..0f36839955 100644 --- a/apps/cli/src/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/stop/SIDE_EFFECTS.md @@ -7,9 +7,10 @@ model (see the CLI-1324 plan's "Critical architectural finding" for why). ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | -------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | default path only — skipped entirely when `--project-id` or `--all` is set | +| Path | Format | When | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | default path only — skipped entirely when `--project-id` or `--all` is set | +| `auth.email.template.*` / `auth.email.notification.*` `content_path` (config-relative or absolute) | text (existence/readability only — bytes discarded, used only to validate the config) | default path only, only when `auth.enabled`, for every configured template and every notification with `enabled = true`, as part of `legacyResolveLocalConfigValues`'s own `Config.Validate` pass; the resolved path is CONFINED to the project root (symlinks dereferenced with `realpathSync`) — a path resolving outside it aborts before the read | ## Files Written @@ -129,8 +130,17 @@ Same payload as `json`, delivered as a `result` NDJSON event. ## Notes - `--project-id` and `--all` are **directory-independent** pure Docker-label filters — - neither reads `config.toml`. Only the no-flags default path resolves the project id + neither reads `config.toml`, so neither is subject to the `content_path` containment + check below; only the no-flags default path resolves the project id from `LegacyCliSettings.workdir` (env → config.toml `project_id` → workdir basename). +- **The default path VALIDATES config, including the `content_path` containment check + above, BEFORE any Docker teardown call.** `resolveSearchProjectIdFilter` + (`stop.handler.ts`) loads and validates config (`legacyResolveLocalConfigValues`) to + resolve the project id filter, and this runs before `legacyDockerRemoveAll` is ever + invoked — so a config-validation failure here (a malformed config, or an + `auth.email.*.content_path` that resolves outside the project root) fails the command + and the running stack is **not** torn down. `--all`/`--project-id` bypass config + loading entirely (see the bullet above) and so are unaffected by this failure mode. - The hidden `--backup` flag exists only for CLI surface parity with the old Go CLI — it has **no effect**. The old Go CLI declared it but never wired its value into anything, so it always deleted volumes based on `!noBackup` regardless of `--backup`. The TS port