fix(stacks): refuse file-explorer delete/rename/chmod on protected stack files (#1202)

* fix(stacks): refuse file-explorer delete/rename/chmod on protected stack files

Previously the per-stack file explorer treated PROTECTED_STACK_FILES
(compose.yaml, compose.yml, docker-compose.yaml/.yml, .env) as a UI
hint only. A direct API call from any user with stack:edit could
delete or rename compose.yaml and break the stack irrecoverably
because the next deploy would fail to find a compose file and the
write was unrecoverable without a DB backup. The frontend
DeleteFileConfirm enforced a type-to-confirm gate but a stale UI or a
scripted client bypassed it.

FileSystemService now refuses the destructive ops at the service layer
with a new PROTECTED_FILE error code that the route layer surfaces as
409. The compose-editor save path (PUT /files/content) and the
upload-overwrite path (POST /files/upload writing a same-named file)
remain unblocked because both are legitimate ways to update
compose.yaml. Tests pin the allowed paths so a future tightening can
not silently regress them.

The protection is scoped to entries at the stack root; subdirectory
files happen to share a protected name (e.g., a snapshot under
backups/compose.yaml) are not blocked because compose CLI only reads
the root file. A trailing-slash bypass is closed by stripping trailing
separators before the basename check.

Frontend DeleteFileConfirm needs no change. Its existing toast.error
surface renders the friendly server message.

* fix(stack-files): drop polynomial regex from protected-file helpers

CodeQL js/polynomial-redos flagged the /\/+$/ pattern used to strip
trailing slashes from relPath in isProtectedRelPath and
protectedFileError. The regex is bounded in practice (the upstream
validator rejects '//' anywhere in the path) but the static analyzer
cannot follow that dataflow guarantee and would have flagged any
future caller that skips the validator.

Replace the two callsites with a small stripTrailingSlash helper that
uses endsWith + slice. Bounded O(1), no regex, no analyzer alert. The
inline comment documents the upstream invariant so a future reader
does not reintroduce the /+ quantifier.
This commit is contained in:
Anso
2026-05-24 23:06:05 -04:00
committed by GitHub
parent 4c28b37a59
commit 37b12379c1
3 changed files with 142 additions and 1 deletions
+31
View File
@@ -35,6 +35,33 @@ const PROTECTED_STACK_FILES = new Set([
'.env',
]);
// Strips at most one trailing slash. The upstream validator
// (isValidRelativeStackPath) rejects any '//' sequence, so a string reaching
// this helper can carry at most one trailing slash, and a single slice is
// sufficient. Avoids the polynomial regex /\/+$/ that CodeQL would flag for
// callers without the upstream length guarantee.
function stripTrailingSlash(s: string): string {
return s.endsWith('/') ? s.slice(0, -1) : s;
}
function isProtectedRelPath(relPath: string): boolean {
if (!relPath) return false;
const normalized = stripTrailingSlash(relPath);
// Only files at the stack root are protected; compose CLI reads compose.yaml from
// the stack directory itself, so a subdirectory entry named compose.yaml is just
// an arbitrary file and the user may want to delete it.
if (normalized.includes('/')) return false;
return PROTECTED_STACK_FILES.has(normalized);
}
function protectedFileError(relPath: string): Error & { code: string } {
const basename = stripTrailingSlash(relPath).split('/').pop() ?? relPath;
return Object.assign(
new Error(`${basename} is a protected stack file. Delete the stack itself via Stack Actions instead.`),
{ code: 'PROTECTED_FILE' as const },
);
}
const MIME_MAP: Record<string, string> = {
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
@@ -685,6 +712,7 @@ export class FileSystemService {
}
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false): Promise<void> {
if (isProtectedRelPath(relPath)) throw protectedFileError(relPath);
const safePath = await this.resolveSafeStackPath(stackName, relPath);
if (recursive) {
@@ -717,6 +745,8 @@ export class FileSystemService {
}
async renameStackPath(stackName: string, fromRel: string, toRel: string): Promise<void> {
if (isProtectedRelPath(fromRel)) throw protectedFileError(fromRel);
if (isProtectedRelPath(toRel)) throw protectedFileError(toRel);
const fromPath = await this.resolveSafeStackPath(stackName, fromRel);
// toRel must resolve to the same parent directory (rename only, no cross-dir move).
const toPath = await this.resolveSafeStackPath(stackName, toRel);
@@ -749,6 +779,7 @@ export class FileSystemService {
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
throw Object.assign(new Error('Invalid permission bits'), { code: 'INVALID_PATH' });
}
if (isProtectedRelPath(relPath)) throw protectedFileError(relPath);
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await fsPromises.chmod(safePath, mode);
}