diff --git a/.github/workflows/issue-416-security-round4.yml b/.github/workflows/issue-416-security-round4.yml new file mode 100644 index 000000000..71f514ae3 --- /dev/null +++ b/.github/workflows/issue-416-security-round4.yml @@ -0,0 +1,136 @@ +name: Issue 416 Security Round 4 + +on: + push: + branches: + - feat/issue-416-backup-destinations + paths: + - .github/workflows/issue-416-security-round4.yml + +permissions: + contents: write + +jobs: + fix-and-verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: feat/issue-416-backup-destinations + - uses: pnpm/action-setup@v5 + with: + version: 10.22.0 + - uses: actions/setup-node@v5 + with: + node-version: 24.4.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Apply guarded final hardening + run: | + python - <<'PY' + from pathlib import Path + + def replace_once(text, old, new, label): + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one match, found {count}") + return text.replace(old, new, 1) + + # Force certificate verification on for FTP even if the execution + # environment sets insecure rclone defaults. CLI options override env. + utils = Path("packages/server/src/utils/backups/utils.ts") + text = utils.read_text() + old = '''\t\tif (destination.secretAccessKey) {\n\t\t\tconst obscuredPassword = await obscureRclonePassword(\n\t\t\t\tdestination.secretAccessKey,\n\t\t\t);\n\t\t\tflags.push(`--${backend}-pass=${quote([obscuredPassword])}`);\n\t\t}\n\t\tflags.push(...additionalFlags);\n\t\treturn {\n''' + new = '''\t\tif (destination.secretAccessKey) {\n\t\t\tconst obscuredPassword = await obscureRclonePassword(\n\t\t\t\tdestination.secretAccessKey,\n\t\t\t);\n\t\t\tflags.push(`--${backend}-pass=${quote([obscuredPassword])}`);\n\t\t}\n\t\tflags.push(...additionalFlags);\n\t\tif (provider === RCLONE_DESTINATION_PROVIDERS.FTP) {\n\t\t\t// Command-line flags override RCLONE_* environment defaults. Keep\n\t\t\t// certificate verification enabled even on a misconfigured runner.\n\t\t\tflags.push(\n\t\t\t\t"--ftp-no-check-certificate=false",\n\t\t\t\t"--no-check-certificate=false",\n\t\t\t);\n\t\t}\n\t\treturn {\n''' + text = replace_once(text, old, new, "FTP secure CLI overrides") + utils.write_text(text) + + # Redact a complete POSIX shell word, including concatenated quoted + # and escaped chunks emitted by shell-quote for embedded quotes/spaces. + redact = Path("packages/server/src/utils/backups/redact.ts") + text = redact.read_text() + old_regex = r'''\t\t/(--(?:s3-access-key-id|s3-secret-access-key|ftp-pass|sftp-pass|sftp-key-file-pass)=)(?:\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*'|[^\\s]+)/g,''' + new_regex = r'''\t\t/(--(?:s3-access-key-id|s3-secret-access-key|ftp-pass|sftp-pass|sftp-key-file-pass)=)(?:(?:\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*'|\\\\[^\\r\\n]|[^\\s\"'\\\\])+)/g,''' + if old_regex not in text: + raise SystemExit("redaction regex anchor not found") + text = text.replace(old_regex, new_regex, 1) + redact.write_text(text) + + # Real shell-quote adversarial regression coverage. + test = Path("apps/dokploy/__test__/backups/redact-credentials.test.ts") + text = test.read_text() + if 'from "shell-quote"' not in text: + text = text.replace( + 'import { describe, expect, it } from "vitest";', + 'import { quote } from "shell-quote";\nimport { describe, expect, it } from "vitest";', + 1, + ) + extra = '''\n\tit("should fully redact shell-quote output with embedded quotes and whitespace", () => {\n\t\tconst secret = `PART_A' PART_B\\" $PART_C;\\\\PART_D`;\n\t\tconst cmd = `rclone lsf --s3-secret-access-key=${quote([secret])} --s3-region=us-east-1 :s3:bucket`;\n\t\tconst redacted = redactRcloneCredentials(cmd);\n\n\t\tfor (const fragment of ["PART_A", "PART_B", "PART_C", "PART_D"]) {\n\t\t\texpect(redacted).not.toContain(fragment);\n\t\t}\n\t\texpect(redacted).toContain('--s3-secret-access-key="[REDACTED]"');\n\t\texpect(redacted).toContain("--s3-region=us-east-1");\n\t});\n''' + idx = text.rfind("\n});") + if idx == -1: + raise SystemExit("redaction describe end not found") + if "fully redact shell-quote output" not in text: + text = text[:idx] + extra + text[idx:] + test.write_text(text) + + # Verify secure CLI override generation at runtime. + backup_test = Path("apps/dokploy/__test__/utils/backups.test.ts") + text = backup_test.read_text() + extra = '''\n\ttest("forces certificate verification on after user flags", async () => {\n\t\tconst result = await getRclonePathAndFlags(\n\t\t\tdestination({\n\t\t\t\tprovider: RCLONE_DESTINATION_PROVIDERS.FTP,\n\t\t\t\tendpoint: "storage.example.com",\n\t\t\t\taccessKey: "backup-user",\n\t\t\t\tsecretAccessKey: "",\n\t\t\t\tregion: "",\n\t\t\t\tbucket: "backups",\n\t\t\t\tadditionalFlags: ["--ftp-explicit-tls"],\n\t\t\t}),\n\t\t);\n\t\texpect(result.flags.slice(-2)).toEqual([\n\t\t\t"--ftp-no-check-certificate=false",\n\t\t\t"--no-check-certificate=false",\n\t\t]);\n\t});\n''' + idx = text.rfind("\n});") + if idx == -1: + raise SystemExit("FTP TLS describe end not found") + if "forces certificate verification on after user flags" not in text: + text = text[:idx] + extra + text[idx:] + backup_test.write_text(text) + PY + - name: Format changed files + run: | + pnpm exec biome check --write \ + packages/server/src/utils/backups/utils.ts \ + packages/server/src/utils/backups/redact.ts \ + apps/dokploy/__test__/backups/redact-credentials.test.ts \ + apps/dokploy/__test__/utils/backups.test.ts + - name: Focused backup and security regressions + run: | + pnpm --filter=dokploy exec vitest --config __test__/vitest.config.ts \ + __test__/backups/redact-credentials.test.ts \ + __test__/utils/backups.test.ts \ + __test__/utils/issue-416-path-safety.test.ts --run + - name: Typecheck + run: pnpm typecheck + - name: Server build + run: pnpm server:build + - name: Biome clean + run: | + pnpm exec biome check \ + packages/server/src/utils/backups/utils.ts \ + packages/server/src/utils/backups/redact.ts \ + apps/dokploy/__test__/backups/redact-credentials.test.ts \ + apps/dokploy/__test__/utils/backups.test.ts + - name: Verify final security invariants + run: | + python - <<'PY' + from pathlib import Path + u=Path("packages/server/src/utils/backups/utils.ts").read_text() + assert '"--ftp-no-check-certificate=false"' in u + assert '"--no-check-certificate=false"' in u + r=Path("packages/server/src/utils/backups/redact.ts").read_text() + assert "\\\\[^\\r\\n]" in r + t=Path("apps/dokploy/__test__/backups/redact-credentials.test.ts").read_text() + assert 'fully redact shell-quote output' in t + print("environment-default and shell-word redaction invariants verified") + PY + - name: Commit verified hardening + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + packages/server/src/utils/backups/utils.ts \ + packages/server/src/utils/backups/redact.ts \ + apps/dokploy/__test__/backups/redact-credentials.test.ts \ + apps/dokploy/__test__/utils/backups.test.ts + if ! git diff --cached --quiet; then + git commit -m "fix: harden rclone environment and credential redaction" + git push origin HEAD:feat/issue-416-backup-destinations + fi