morphit/apps/ops-cli/scripts/upgrade-fetch-hardening-smoke.ts

278 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* upgrade-fetch-hardening smoke (cp160 F-opscli-1).
*
* The ops-cli `upgrade` command fetches the Forgejo releases-latest
* JSON from the operator-configured host (defaults to
* git.agorise.net) to discover the newest Morphit release before
* downloading + SHA-verifying the archive.
*
* Pre-cp160, `fetchLatestRelease()` in
* `apps/ops-cli/src/commands/upgrade.ts` did `await res.json()`
* with no body bound and no `redirect: 'manual'`. The cp160
* apps/ops-cli audit (cp146 finding lens applied to the small
* workspaces) closed this as F-opscli-1.
*
* Threat model: the host is operator-configured, so SSRF isn't the
* canonical attack. The exposure is a MITM'd or compromised release
* API returning a multi-GB JSON that OOMs the operator's upgrade run,
* or a 30x redirect to an unexpected host on the metadata call.
* LOW severity (operator-run CLI, operator-controlled host) but the
* body cap + redirect:manual are cheap defense-in-depth consistent
* with the cp151 / cp159 body-cap pattern across the codebase.
*
* The downloaded archive itself is SHA-256 verified downstream
* (parseShaFile + computeSha256), so a tampered archive is already
* caught — this smoke covers only the metadata-JSON fetch which had
* no such downstream guard.
*
* `fetchLatestRelease()` is private (not exported), so this is a
* source-sentinel smoke (same pattern as cp156 root-shell-then-
* redirect-smoke + cp159 price-fetch-util-smoke source-sentinels):
* pin the load-bearing source text so a future refactor that removes
* the cap or the redirect:manual is caught.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const ANSI_GREEN = '\x1b[32m';
const ANSI_RED = '\x1b[31m';
const ANSI_RESET = '\x1b[0m';
interface Result {
name: string;
passed: boolean;
detail?: string;
}
const results: Result[] = [];
function pass(name: string) {
results.push({ name, passed: true });
}
function fail(name: string, detail: string) {
results.push({ name, passed: false, detail });
}
const UPGRADE_PATH = resolve(
new URL('../src/commands/upgrade.ts', import.meta.url).pathname
);
const src = readFileSync(UPGRADE_PATH, 'utf8');
/* ---------------- local strip-comments (see cp159 Lesson #1) ---------------- */
// Inlined rather than cross-importing scripts/lib/strip-comments.ts —
// the 3-level relative path resolves awkwardly under tsx --tsconfig
// from apps/*/scripts/. Same 3-line shape as the canonical helper.
const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
const LINE_COMMENT_RE = /\/\/[^\n]*/g;
function stripComments(s: string): string {
return s.replace(BLOCK_COMMENT_RE, '').replace(LINE_COMMENT_RE, '');
}
const codeOnly = stripComments(src);
/* ---------------- scenario 1: redirect:manual on release fetch ---------------- */
if (/redirect:\s*'manual'/.test(codeOnly)) {
pass("fetchLatestRelease uses redirect: 'manual'");
} else {
fail(
"fetchLatestRelease uses redirect: 'manual'",
'no redirect:manual found in upgrade.ts code (after comment-strip)'
);
}
/* ---------------- scenario 2: Content-Length pre-check ---------------- */
if (
codeOnly.includes("res.headers.get('content-length')") &&
/RELEASE_JSON_MAX_BYTES/.test(codeOnly)
) {
pass('fetchLatestRelease has Content-Length pre-check against RELEASE_JSON_MAX_BYTES');
} else {
fail(
'fetchLatestRelease has Content-Length pre-check',
'missing content-length header check or RELEASE_JSON_MAX_BYTES constant'
);
}
/* ---------------- scenario 3: post-text length cap ---------------- */
if (/text\.length\s*>\s*RELEASE_JSON_MAX_BYTES/.test(codeOnly)) {
pass('fetchLatestRelease has post-text length cap (catches absent/lying Content-Length)');
} else {
fail(
'fetchLatestRelease has post-text length cap',
'missing text.length > RELEASE_JSON_MAX_BYTES guard'
);
}
/* ---------------- scenario 4: no bare await res.json() in release fetch ---------------- */
// After cp160, the release fetch MUST use res.text() + JSON.parse with
// the cap in between, not bare res.json(). The downloadTo() path uses
// res.arrayBuffer() (binary archive) which is fine and separate.
// Count bare res.json() occurrences in code (not comments).
const bareJsonCount = (codeOnly.match(/await\s+res\.json\(\)/g) || []).length;
if (bareJsonCount === 0) {
pass('no bare `await res.json()` in upgrade.ts (release fetch uses capped text + JSON.parse)');
} else {
fail(
'no bare `await res.json()` in upgrade.ts',
`found ${bareJsonCount} occurrence(s); release-metadata fetch must cap body before parse`
);
}
/* ---------------- scenario 5: cap value is sane ---------------- */
// Pin the cap at 1 MiB — 100x+ the ~8 KB normal Forgejo release-latest
// payload. If a future edit drops it to something absurdly small
// (would break legit upgrades) or huge (defeats the defense), flag it.
const capMatch = codeOnly.match(/RELEASE_JSON_MAX_BYTES\s*=\s*([\d*\s]+);/);
if (capMatch) {
// Evaluate the simple arithmetic expression (e.g. "1024 * 1024").
const expr = capMatch[1]!.trim();
let capValue: number | null = null;
if (/^[\d\s*]+$/.test(expr)) {
capValue = expr
.split('*')
.map((s) => Number(s.trim()))
.reduce((a, b) => a * b, 1);
}
if (capValue !== null && capValue >= 64 * 1024 && capValue <= 16 * 1024 * 1024) {
pass(`RELEASE_JSON_MAX_BYTES is sane (${capValue} bytes, between 64 KiB and 16 MiB)`);
} else {
fail(
'RELEASE_JSON_MAX_BYTES is sane',
`cap value ${capValue} outside expected 64 KiB 16 MiB range`
);
}
} else {
fail('RELEASE_JSON_MAX_BYTES is sane', 'could not locate the cap constant assignment');
}
/* ---------------- scenario 6: cp160 attribution present ---------------- */
if (src.includes('F-opscli-1')) {
pass('cp160 F-opscli-1 attribution present in source');
} else {
fail('cp160 F-opscli-1 attribution present in source', 'no F-opscli-1 reference found');
}
/* ---------------- cp189: config/keystore carry-forward ---------------- */
// The upgrade renames the old install to .bak then extracts a FRESH
// tarball that does NOT contain the operator's config or signing key.
// Without a carry-forward step the operator's config/keystore would be
// stranded in the backup and the instance would come up empty. These
// pin the fix: the step must exist, must sit BETWEEN extract and npm
// ci, must copy each operator-data path, and must rollback on failure.
// 1. the preserve list names every operator-written path inside the tree
{
const needs = [
'morphit.config.env',
'morphit.env',
'apps/relay/keystore.json',
'apps/relay/keystore.wif',
'apps/relay/altnet',
'morphit-hardening-checklist.md'
];
const missing = needs.filter((n) => !codeOnly.includes(n));
if (missing.length === 0) {
pass('carry-forward preserves config + keystore + altnet + checklist');
} else {
fail(
'carry-forward preserves config + keystore + altnet + checklist',
'preserve list missing: ' + missing.join(', ')
);
}
}
// 2. uses copyFileSync/cpSync (perm-preserving) — NOT a perm-losing write
if (codeOnly.includes('copyFileSync') && codeOnly.includes('cpSync')) {
pass('carry-forward uses copyFileSync/cpSync (preserves 0600)');
} else {
fail('carry-forward uses copyFileSync/cpSync (preserves 0600)', 'expected copyFileSync + cpSync');
}
// 3. carry-forward happens AFTER extract and BEFORE npm ci
{
const extractIdx = codeOnly.indexOf("'tar'");
const carryIdx = codeOnly.indexOf('preserve');
const npmCiIdx = codeOnly.indexOf("'ci'");
if (extractIdx !== -1 && carryIdx !== -1 && npmCiIdx !== -1 && extractIdx < carryIdx && carryIdx < npmCiIdx) {
pass('carry-forward sits between extract and npm ci');
} else {
fail(
'carry-forward sits between extract and npm ci',
`ordering wrong (extract=${extractIdx}, carry=${carryIdx}, npmCi=${npmCiIdx})`
);
}
}
// 4. failure during carry-forward rolls back (no half-migrated install)
{
// find the carry-forward try/catch and confirm it calls rollback
const afterCarry = codeOnly.slice(codeOnly.indexOf('preserve'));
if (/catch[\s\S]{0,200}rollback\(installDir, backupDir, tmpDir/.test(afterCarry)) {
pass('carry-forward failure rolls back');
} else {
fail('carry-forward failure rolls back', 'no rollback() in the carry-forward catch');
}
}
/* ---------------- cp191: prerelease-aware release discovery ---------------- */
// `/releases/latest` returns only the newest NON-prerelease release
// (Forgejo API semantics). During the beta period every release is
// a prerelease, so that endpoint 404s and upgrade saw nothing. The
// fix: prefer /releases/latest, fall back to /releases?limit=1 (newest
// of any kind) on 404. Pin both halves so a future edit can't drop
// the fallback and silently re-strand beta operators.
// 1. still queries /releases/latest (stable-preferred default)
// NOTE: checked against raw `src`, not `codeOnly` — the naive
// comment-stripper treats `//` in `https://…` as a line comment and
// would delete the URL. These are URL literals, never in comments.
if (src.includes('/releases/latest')) {
pass('upgrade still prefers /releases/latest (stable-first)');
} else {
fail('upgrade still prefers /releases/latest (stable-first)', 'no /releases/latest query found');
}
// 2. falls back to the list endpoint when no stable exists
if (/releases\?limit=1/.test(src)) {
pass('upgrade falls back to /releases?limit=1 for prereleases');
} else {
fail(
'upgrade falls back to /releases?limit=1 for prereleases',
'no /releases?limit=1 fallback — flagged-pre-release betas would be invisible (the beta1/beta2 trap)'
);
}
// 3. the fallback is gated on a 404 (no stable), not fired unconditionally
if (/status\s*!==\s*404/.test(codeOnly) || /status\s*===\s*404/.test(codeOnly)) {
pass('prerelease fallback is gated on 404 (no stable release)');
} else {
fail('prerelease fallback is gated on 404', 'fallback should trigger only when /releases/latest 404s');
}
/* ---------------- report ---------------- */
let failed = 0;
for (const r of results) {
if (r.passed) {
console.log(' ' + ANSI_GREEN + '✓' + ANSI_RESET + ' ' + r.name);
} else {
console.log(' ' + ANSI_RED + '✗' + ANSI_RESET + ' ' + r.name);
if (r.detail) console.log(' ' + r.detail);
failed++;
}
}
console.log();
console.log('──────────────────────────────────────────────────────');
if (failed > 0) {
console.log('✗ ' + failed + ' of ' + results.length + ' scenarios failed');
process.exit(1);
} else {
console.log('✓ all ' + results.length + ' scenarios passed');
}