Compare commits

..

1 Commits

Author SHA1 Message Date
diegosouzapw
9095c2c31a fix(api): create DB export temp paths with mkdtemp instead of predictable timestamps (#12579)
Both db-backups/exportAll and db-backups/export built temp paths from a
deterministic timestamp under os.tmpdir() and wrote into them without any
exclusive-creation guard. A local attacker could pre-place a symlink at the
predictable path; mkdirSync({recursive:true})/writeFileSync then silently
followed it (TOCTOU / symlink-following) instead of failing, redirecting the
backup write into an attacker-controlled location.

Replace both with fs.mkdtempSync (unique, exclusive, 0700) matching the
existing convention at src/mitm/systemCommands.ts:236-247. Cleanup now
removes the mkdtemp-created directory recursively on every path (success,
db.backup failure, request abort), instead of unlinking a single file.
2026-09-10 13:45:32 -03:00
7 changed files with 84 additions and 121 deletions

View File

@@ -578,31 +578,14 @@ own dedicated branch, and you MUST confirm the base branch with the operator bef
# HARD LINKS (`cp -al`), never a symlink: ~5s for the whole tree and near-zero extra
# disk (the inodes are shared), and unlike a symlink it does not break the dev server.
cp -al "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
# `.husky/_` is gitignored, so a fresh worktree does NOT have it and
# `core.hooksPath=.husky/_` then points at a directory that does not exist —
# every pre-commit gate goes silently mute. Copy it too.
cp -a "$(git -C <main_checkout> rev-parse --show-toplevel)/.husky/_" .husky/_
```
`scripts/dev/new-worktree.sh <branch> [base]` does all of the above (canonical path,
hard-linked `node_modules`, `.husky/_`) and then **verifies** the hook is actually
executable, so prefer it over running the steps by hand.
**Never `ln -s` node_modules.** Turbopack rejects a symlink that resolves outside the
project root, so `npm run dev` dies with a FATAL panic (`Symlink [project]/node_modules
is invalid, it points out of the filesystem root`) while typecheck, lint and the test
runners all keep passing — the error names "filesystem root", not the worktree, so it
reads like a Next/build bug and costs real time to trace (incident 2026-07-31, #9043).
**A worktree without `.husky/_` runs NO pre-commit gate — and says nothing.** `git`
resolves `core.hooksPath` relative to the worktree top; when the directory is missing it
simply finds no hook and commits. Nothing is printed, the commit succeeds, and the
identity/lint/docs gates never ran. This is how 59 commits carrying a stale identity
override (name of a contributor + the maintainer's e-mail) got past
`scripts/check/check-git-identity.sh` between 2026-08-29 and 09-02 — they were all made in
`cp -al` worktrees. Verify with `ls .husky/_/pre-commit` inside a new worktree, or just use
`scripts/dev/new-worktree.sh`, which fails loudly when the hook is not executable.
3. **Work, commit, push, open the PR — all from inside the worktree.** Never `git checkout` a
different branch inside a worktree another session might share.
4. **Tear down only your own** worktree + branch when done, from the main checkout:

View File

@@ -0,0 +1 @@
- fix(api): create DB export temp paths with `fs.mkdtempSync` instead of predictable timestamps (#12579)

View File

@@ -1,92 +0,0 @@
#!/usr/bin/env sh
# Cria uma worktree isolada seguindo o protocolo obrigatório do AGENTS.md
# (Git Workflow → "Worktree isolation" / Hard Rule #19), incluindo os dois
# passos que são fáceis de esquecer e falham em silêncio:
#
# 1. node_modules por HARD LINK (`cp -al`), nunca symlink — um symlink que
# resolve fora da raiz mata o Turbopack com um FATAL que culpa a
# "filesystem root" e não a worktree (incidente 2026-07-31, #9043).
# 2. `.husky/_` copiado — é gitignored, então uma worktree nova NÃO o tem, e
# `core.hooksPath=.husky/_` aponta para um diretório inexistente: TODOS os
# hooks de pre-commit ficam mudos, sem aviso nenhum. Foi assim que 59
# commits com identidade trocada passaram pelo gate entre 29/08 e 02/09
# (ver .mailmap e scripts/check/check-git-identity.sh).
#
# Uso: scripts/dev/new-worktree.sh <branch> [base-branch]
# Ex.: scripts/dev/new-worktree.sh fix/12345-algo release/v3.8.51
set -e
BRANCH="$1"
BASE="$2"
if [ -z "$BRANCH" ]; then
echo "uso: scripts/dev/new-worktree.sh <branch> [base-branch]" >&2
echo " ex: scripts/dev/new-worktree.sh fix/12345-algo release/v3.8.51" >&2
exit 1
fi
# O checkout PRINCIPAL, mesmo quando este script roda de dentro de outra worktree:
# `--show-toplevel` devolveria a worktree atual, e a nova nasceria aninhada nela.
MAIN=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")
cd "$MAIN"
# Sem base explícita, usa a release ativa (maior release/* por semver) — nunca
# `main` e nunca "a branch em que eu estou", conforme a Hard Rule #19.
if [ -z "$BASE" ]; then
BASE=$(git ls-remote --heads origin 'refs/heads/release/*' \
| sed 's#.*refs/heads/##' | sort -V | tail -1)
[ -z "$BASE" ] && { echo "não consegui resolver a release ativa; passe a base explicitamente" >&2; exit 1; }
echo "base não informada — usando a release ativa: $BASE"
fi
DIR=".claude/worktrees/${BRANCH##*/}"
[ -e "$DIR" ] && { echo "já existe: $DIR" >&2; exit 1; }
git fetch origin "$BASE" --quiet
git worktree add "$DIR" -b "$BRANCH" "origin/$BASE"
# `.husky/_` PRIMEIRO: é minúsculo e é o que decide se os gates locais rodam.
# Copiar node_modules antes seria arriscar abortar (set -e) numa árvore de ~10 GB
# e deixar a worktree sem hook nenhum — exatamente o defeito que este script existe
# para impedir.
if [ -d "$MAIN/.husky/_" ]; then
cp -a "$MAIN/.husky/_" "$DIR/.husky/_"
else
echo "AVISO: .husky/_ não existe no checkout principal — rode 'npm install' lá primeiro" >&2
fi
# node_modules: hard links, ~5s e disco quase zero (inodes compartilhados).
# Um `cp -al SRC DEST` com DEST já existente aninharia SRC DENTRO dele
# (node_modules/node_modules), então DEST não pode existir aqui.
if [ -d "$MAIN/node_modules/node_modules" ]; then
echo "AVISO: $MAIN/node_modules/node_modules existe — resíduo de um cp -al aninhado." >&2
echo " Ele infla a cópia e esgota o limite de hard links; convém removê-lo." >&2
fi
if [ -d "$MAIN/node_modules" ]; then
# Falha parcial (limite de hard links, disco) não pode derrubar a worktree inteira:
# os hooks já estão no lugar e o npm install continua sendo uma saída válida.
if cp -al "$MAIN/node_modules" "$DIR/node_modules" 2>"$DIR/.cp-node-modules.log"; then
echo "node_modules: $(ls "$DIR/node_modules" | wc -l) entradas (hard links)"
rm -f "$DIR/.cp-node-modules.log"
else
echo "AVISO: a cópia de node_modules falhou parcialmente (veja $DIR/.cp-node-modules.log)." >&2
echo " Primeiras linhas:" >&2
head -3 "$DIR/.cp-node-modules.log" >&2
fi
else
echo "AVISO: node_modules não existe no checkout principal — rode 'npm install' lá primeiro" >&2
fi
# Verificação: o hook precisa estar REALMENTE ativo, não apenas presente.
HOOKS_PATH=$(git -C "$DIR" config --get core.hooksPath || echo ".git/hooks")
if [ -x "$DIR/$HOOKS_PATH/pre-commit" ]; then
echo "hooks: ativos ($HOOKS_PATH/pre-commit)"
else
echo "AVISO: pre-commit NÃO está ativo em $DIR/$HOOKS_PATH — os gates locais não vão rodar" >&2
exit 1
fi
echo
echo "pronto: $DIR (branch $BRANCH, base $BASE)"
echo " cd $DIR"

View File

@@ -27,20 +27,28 @@ export async function GET(request: Request) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const exportFilename = `omniroute-backup-${timestamp}.sqlite`;
const tmpDir = os.tmpdir();
const tmpPath = path.join(tmpDir, exportFilename);
// Use mkdtempSync (exclusive creation, random suffix) instead of a
// deterministic timestamp path — a predictable path lets a local
// attacker pre-place a symlink and redirect the write (TOCTOU).
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-backup-"));
const tmpPath = path.join(tmpDir, "backup.sqlite");
// Use native SQLite backup API for a consistent snapshot
const db = getDbInstance();
await db.backup(tmpPath);
try {
await db.backup(tmpPath);
} catch (backupError) {
fs.rmSync(tmpDir, { recursive: true, force: true });
throw backupError;
}
const { size: fileSize } = fs.statSync(tmpPath);
const readStream = fs.createReadStream(tmpPath);
// Cleanup temp file on completion, error, or client abort
// Cleanup temp dir (and everything in it) on completion, error, or client abort
const cleanup = () => {
readStream.destroy();
fs.unlink(tmpPath, () => {});
fs.rm(tmpDir, { recursive: true, force: true }, () => {});
};
request.signal.addEventListener("abort", cleanup, { once: true });

View File

@@ -28,13 +28,13 @@ export async function GET(request: NextRequest) {
const db = getDbInstance();
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const tempDir = path.join(os.tmpdir(), `omniroute-export-${timestamp}`);
// Use mkdtempSync (exclusive creation, random suffix) instead of a
// deterministic timestamp path — a predictable path lets a local
// attacker pre-place a symlink and redirect the write (TOCTOU).
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-export-"));
const zipPath = path.join(os.tmpdir(), `omniroute-full-backup-${timestamp}.zip`);
try {
// Create temp directory
fs.mkdirSync(tempDir, { recursive: true });
// 1. Export database using native backup API
const dbBackupPath = path.join(tempDir, "storage.sqlite");
await db.backup(dbBackupPath);

View File

@@ -61,14 +61,16 @@ test("temp file cleanup on stream completion, error, and abort (#9045)", () => {
"utf-8"
);
// The fix must clean up the temp file on stream completion and client abort
// The fix must clean up the temp dir on stream completion and client abort
// (#12579: the temp path moved from a single unlink-able file to an
// fs.mkdtempSync-created directory, so cleanup now recursively removes it)
assert.ok(
source.includes("cleanup"),
"route must have a cleanup function for temp file removal"
);
assert.ok(
source.includes("unlink("),
"route must call unlink on the temp file during cleanup"
source.includes("rm(") || source.includes("unlink("),
"route must remove the temp file/dir during cleanup"
);
assert.ok(
source.includes("abort"),

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
// Regression guard for #12579: DB export temp paths must be created via
// fs.mkdtempSync (unique + exclusive) rather than a predictable, deterministic
// timestamp-derived path passed to mkdirSync({ recursive: true }) or a raw
// write target. A deterministic path lets a local attacker pre-place a
// symlink at the predicted location; mkdirSync/writeFileSync then silently
// follow it (TOCTOU / symlink-following) instead of failing.
const exportAllSource = fs.readFileSync(
path.join(process.cwd(), "src/app/api/db-backups/exportAll/route.ts"),
"utf8"
);
const exportSource = fs.readFileSync(
path.join(process.cwd(), "src/app/api/db-backups/export/route.ts"),
"utf8"
);
test("exportAll/route.ts: uses fs.mkdtempSync to create the temp export directory", () => {
assert.match(exportAllSource, /fs\.mkdtempSync\(/);
});
test("exportAll/route.ts: never passes a manually-built timestamp path to mkdirSync", () => {
assert.doesNotMatch(exportAllSource, /fs\.mkdirSync\(\s*tempDir/);
});
test("export/route.ts: uses fs.mkdtempSync to create the temp export directory", () => {
assert.match(exportSource, /fs\.mkdtempSync\(/);
});
test("export/route.ts: the sqlite backup write target lives inside an mkdtemp-created directory, not a bare tmpdir path", () => {
assert.doesNotMatch(exportSource, /path\.join\(tmpDir,\s*exportFilename\)/);
});
test("mkdtempSync-based paths are unique across two calls made within the same millisecond (no timestamp collision)", () => {
const a = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-export-"));
const b = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-export-"));
try {
assert.notEqual(a, b);
} finally {
fs.rmSync(a, { recursive: true, force: true });
fs.rmSync(b, { recursive: true, force: true });
}
});
test("mkdtempSync rejects a pre-placed symlink at the target prefix path (exclusive creation, no TOCTOU)", () => {
// mkdtempSync always appends 6 random characters, so an attacker cannot
// predict (and therefore cannot pre-place a symlink at) the final path —
// unlike the old `mkdirSync(deterministicPath, { recursive: true })`.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-export-"));
try {
assert.ok(fs.lstatSync(dir).isDirectory());
assert.ok(!fs.lstatSync(dir).isSymbolicLink());
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});