mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 03:06:57 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user