fix(blueprints): fail closed on marker ownership for apply and withdraw (#1694)

* fix(blueprints): fail closed on marker ownership for apply and withdraw

Require a matching .blueprint.json under the stack lock, persist required_blueprint_id on deletion intents, remove the legacy remote apply fallback, and protect the marker in the file explorer.

* fix(blueprints): add CodeQL path barriers on ownership probes

Use the canonical resolve-and-startsWith sanitizer inline at the marker and stack-directory fs sinks so js/path-injection clears.

* fix(blueprints): block delete on failed withdraw and defer marker write

Refuse Blueprint DELETE when pre-delete withdraw does not complete, and write .blueprint.json only after a successful deploy so failed applies cannot orphan stacks or claim an unapplied revision.

* test(blueprints): align lock-order assert with deferred marker write

Update the per-stack lock ordering expectations to compose, cleanup, deploy, then marker after the partial-apply fix.

* fix(deps): bump postcss past GHSA-r28c-9q8g-f849 for npm audit

Raise the Vitest/Vite transitive postcss to 8.5.23 so Backend CI audit --audit-level=high passes.
This commit is contained in:
Anso
2026-07-24 15:57:18 -04:00
committed by GitHub
parent e33eda3c38
commit 17a8dc8a94
19 changed files with 1092 additions and 286 deletions
+39
View File
@@ -135,6 +135,40 @@ function releaseStackOpLock(req: Request, stackName: string): void {
StackOpLockService.getInstance().release(req.nodeId, stackName);
}
/** Root compose + blueprint marker on the stack-source root must not change while a lifecycle op holds the stack lock. */
const STACK_OP_LOCKED_ROOT_TRUST_FILES = new Set([
'compose.yaml',
'compose.yml',
'docker-compose.yaml',
'docker-compose.yml',
'.blueprint.json',
]);
function rejectIfStackOpBlocksRootTrustFileWrite(
req: Request,
res: Response,
stackName: string,
relPath: string,
root: StackFileRoot,
): boolean {
if (root.kind !== 'stack-source') return false;
if (relPath.includes('/')) return false;
const base = relPath.toLowerCase();
if (!STACK_OP_LOCKED_ROOT_TRUST_FILES.has(base)) return false;
const existing = StackOpLockService.getInstance().get(req.nodeId, stackName);
if (!existing) return false;
res.status(409).json({
error: `${stackName} is busy: another operation (${existing.action}) is already in progress`,
code: 'stack_op_in_progress',
inProgress: {
action: existing.action,
startedAt: existing.startedAt,
user: existing.user,
},
});
return true;
}
function stackFileEtag(mtimeMs: number): string {
return `W/"${Math.floor(mtimeMs)}"`;
}
@@ -2884,6 +2918,10 @@ stacksRouter.post(
return res.status(400).json({ error: 'Invalid filename' });
}
const targetRelPath = relPath ? `${relPath}/${originalName}` : originalName;
if (rejectIfStackOpBlocksRootTrustFileWrite(req, res, stackName, targetRelPath, root)) {
await cleanupUploadTemp(req);
return;
}
const overwrite = String(req.query.overwrite) === '1';
// The multer wrapper stashed the route-entry timestamp on the request so
// the success path and the rejection paths share one window. Fall back to
@@ -2987,6 +3025,7 @@ stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response
const expectedVersion = req.header('if-match') || undefined;
const root = await resolveRootForOp(req, res, stackName, 'write');
if (!root) return;
if (rejectIfStackOpBlocksRootTrustFileWrite(req, res, stackName, relPath, root)) return;
const startedAt = Date.now();
logFileDiag('write start', { stackName, relPath, nodeId: req.nodeId, bytes: Buffer.byteLength(content, 'utf-8'), hasIfMatch: expectedVersion !== undefined, rootKind: root.kind });
try {