Feature: /etc/dashcaddy/operator.env (root-owned KEY=VALUE file) is forwarded into the DashCaddy container as docker -e flags by start.sh. Canonical flag: ALLOW_LIFETIME_LICENSE=true on the CREATOR host only. This is a client-side rail, NOT the security boundary: lifetime codes must HMAC-verify against the creator-only signing secret, which is never shipped to customers (installs carry only .license-secret-hash; the license server never issues lifetime codes). Flipping the flag on a customer host grants nothing. Missing file = normal case (zero flags). Design (per r3 judge blockers): 1. scripts/operator-flags.js — Node, ONE file descriptor: opened with O_RDONLY|O_NOFOLLOW, fstat'ed, and read from that SAME fd. Contract: regular file; no group/other access bits (mode & 077 == 0); no setuid/setgid/sticky; owner uid 0 under root runtime (euid => owner equality otherwise). Symlink refused at open even when the target would pass all checks. No stat-path/reopen-path TOCTOU window. Violation => stderr FATAL + exit 3. Content is pure data: never sourced/evaled/executed; `$(...)` values stay inert text. 2. start.sh: rc 3 => warn + forward NOTHING (fail closed); ANY other non-zero rc => ABORT startup (fail loud, tested policy); missing file => silent zero overrides. Single helper invocation; keys-only audit line at boot (never values). 3. Artifact hygiene: verifier asserts zero full-format license codes in any submission file. (An earlier round leaked one; that code is dead — signing secret rotated, keygen verify reports INVALID.) Root-cause hardening (2026-10-08 incident: deploy checkouts overwriting git-TRACKED runtime state revoked the owner's premium and hid service cards): - untrack dashcaddy-api/data/services.json (stale committed copy had clobbered the live 16-service state incl SamiMail/SamiPanel/Revenue) - untrack dashcaddy-api/.license-secret + .license-counter, extend .gitignore. The secret value in prior git history is already rotated, hence cryptographically dead. Tests: 16 executed operator-flags tests (O_NOFOLLOW symlink-to-valid- target, 0644/0755/setuid/directory/foreign-owner rejections, missing-file-silent, usage error, injection-inert, translation suite); license-manager 76 + tier-enforcement 21 untouched => 113 targeted green.
203 lines
7.6 KiB
JavaScript
203 lines
7.6 KiB
JavaScript
/**
|
|
* DC-164 rev4 — operator-flags.js: contract check + env translation tests
|
|
* (EXECUTED against the shipped node script; nothing mocked).
|
|
*
|
|
* Proven properties:
|
|
* - SECURITY contract on a PINNED file descriptor (O_NOFOLLOW open, fstat,
|
|
* read of the SAME fd — r3 judge TOCTOU blocker): symlink (even to a
|
|
* valid file) => exit 3; directory => 3; group/other readable mode => 3;
|
|
* setuid bit => 3; foreign-owned (as root: uid!=0) => 3; compliant
|
|
* 0600 file owned by the invoking euid => exit 0 + emit.
|
|
* - missing file => exit 0, silent (normal customer case: zero overrides).
|
|
* - valid KEY=VALUE lines pass through verbatim; values with spaces,
|
|
* '#', '@' are DATA; surrounding double quotes stripped.
|
|
* - comments/blank lines skipped; malformed keys skipped with stderr
|
|
* warnings, exit stays 0.
|
|
* - content NEVER executed: `$(touch marker)` values stay inert text.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { spawnSync } = require('child_process');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'operator-flags.js');
|
|
|
|
function runNode(file) {
|
|
const r = spawnSync('node', [SCRIPT, file], { encoding: 'utf8' });
|
|
return { out: r.stdout || '', err: r.stderr || '', code: r.status };
|
|
}
|
|
|
|
function runContents(contents, mode = 0o600) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-'));
|
|
const file = path.join(dir, 'operator.env');
|
|
try {
|
|
fs.writeFileSync(file, contents);
|
|
fs.chmodSync(file, mode);
|
|
return runNode(file);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const IS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
|
|
describe('DC-164 operator-flags.js translation', () => {
|
|
test('valid lines pass through verbatim', () => {
|
|
const { out, code } = runContents('ALLOW_LIFETIME_LICENSE=true\nFOO=bar\n');
|
|
expect(code).toBe(0);
|
|
expect(out).toBe('ALLOW_LIFETIME_LICENSE=true\nFOO=bar\n');
|
|
});
|
|
|
|
test('missing file → exit 0, no output (silent normal case)', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-'));
|
|
try {
|
|
const { out, code } = runNode(path.join(dir, 'absent.env'));
|
|
expect(code).toBe(0);
|
|
expect(out).toBe('');
|
|
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('comments and blank lines skipped', () => {
|
|
const { out } = runContents('# comment\n\n \nA=1\n');
|
|
expect(out).toBe('A=1\n');
|
|
});
|
|
|
|
test('malformed keys skipped with stderr warning (not fatal)', () => {
|
|
const r = runContents('BAD KEY=oops\n2BAD=1\nexport X=1\n=novalue\nGOOD=ok\n');
|
|
expect(r.code).toBe(0);
|
|
expect(r.out).toBe('GOOD=ok\n');
|
|
expect(r.err).toMatch(/skipping line 1/);
|
|
expect(r.err).toMatch(/skipping line 2/);
|
|
expect(r.err).toMatch(/skipping line 3/);
|
|
expect(r.err).toMatch(/skipping line 4/);
|
|
});
|
|
|
|
test('surrounding double quotes stripped; inner hash is data', () => {
|
|
const { out } = runContents('QUOTED="hello world"\nDB_PASS=p@ss#word\nMIXED="v"\n');
|
|
const lines = out.split('\n').filter(Boolean);
|
|
expect(lines).toContain('QUOTED=hello world');
|
|
expect(lines).toContain('DB_PASS=p@ss#word');
|
|
expect(lines).toContain('MIXED=v');
|
|
});
|
|
|
|
test('injection-looking value stays inert text (never executed)', () => {
|
|
const marker = path.join(os.tmpdir(), 'opflags-PWNED-' + Date.now());
|
|
const { out } = runContents(`X=$(touch ${marker})\nY=; touch ${marker}\n`);
|
|
expect(out).toContain(`X=$(touch ${marker})`);
|
|
expect(out).toContain(`Y=; touch ${marker}`);
|
|
expect(fs.existsSync(marker)).toBe(false);
|
|
});
|
|
|
|
test('last line without trailing newline still emitted', () => {
|
|
const { out } = runContents('A=1\nB=2');
|
|
expect(out).toBe('A=1\nB=2\n');
|
|
});
|
|
|
|
test('empty file → no output, exit 0', () => {
|
|
const { out, code } = runContents('');
|
|
expect(code).toBe(0);
|
|
expect(out).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('DC-164 operator-flags.js security contract (fd-pinned)', () => {
|
|
test('group/world-readable mode → exit 3, FATAL, no output', () => {
|
|
const r = runContents('ALLOW_LIFETIME_LICENSE=true\n', 0o644);
|
|
expect(r.code).toBe(3);
|
|
expect(r.out).toBe('');
|
|
expect(r.err).toMatch(/FATAL/);
|
|
expect(r.err).toMatch(/mode 644/);
|
|
});
|
|
|
|
test('setuid bit on top of 0600 → exit 3', () => {
|
|
const r = runContents('A=1\n', 0o4600);
|
|
expect(r.code).toBe(3);
|
|
expect(r.err).toMatch(/FATAL/);
|
|
});
|
|
|
|
test('world-readable owner-exec 0755 → exit 3', () => {
|
|
const r = runContents('A=1\n', 0o755);
|
|
expect(r.code).toBe(3);
|
|
});
|
|
|
|
test('symlink — even to a compliant 0600 file — → exit 3 (O_NOFOLLOW)', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-sec-'));
|
|
const real = path.join(dir, 'real.env');
|
|
const link = path.join(dir, 'operator.env');
|
|
fs.writeFileSync(real, 'A=1\n');
|
|
fs.chmodSync(real, 0o600);
|
|
fs.symlinkSync(real, link);
|
|
try {
|
|
const r = runNode(link);
|
|
expect(r.code).toBe(3);
|
|
expect(r.out).toBe('');
|
|
expect(r.err).toMatch(/symlink|O_NOFOLLOW/);
|
|
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('directory passed as operator file → exit 3', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-sec-'));
|
|
const sub = path.join(dir, 'operator.env');
|
|
fs.mkdirSync(sub);
|
|
try {
|
|
const r = runNode(sub);
|
|
expect(r.code).toBe(3);
|
|
expect(r.err).toMatch(/not a regular file|cannot open/);
|
|
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('compliant file (owner = invoking euid, 0600) → exit 0 + emit', () => {
|
|
const { out, code } = runContents('ALLOW_LIFETIME_LICENSE=true\n', 0o600);
|
|
expect(code).toBe(0);
|
|
expect(out).toBe('ALLOW_LIFETIME_LICENSE=true\n');
|
|
});
|
|
|
|
test('FIFO never blocks open: exits 3 within 2s (r5 DoS blocker)', (done) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-sec-'));
|
|
const fifo = path.join(dir, 'operator.env');
|
|
try {
|
|
spawnSync('mkfifo', [fifo], { encoding: 'utf8' });
|
|
// No writer is ever attached — open would block forever without the
|
|
// O_NONBLOCK + lstat pre-check. killSignal proves the timeout path.
|
|
const r = spawnSync('node', [SCRIPT, fifo], { encoding: 'utf8', timeout: 2000, killSignal: 'SIGKILL' });
|
|
expect(r.error).toBeUndefined(); // not killed => returned on its own
|
|
expect(r.status).toBe(3);
|
|
expect(r.stderr).toMatch(/not a regular file/);
|
|
expect(r.stdout).toBe('');
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
done();
|
|
}
|
|
});
|
|
|
|
test('usage error: no argument → exit 3 FATAL', () => {
|
|
const r = spawnSync('node', [SCRIPT], { encoding: 'utf8' });
|
|
expect(r.status).toBe(3);
|
|
expect(r.stderr).toMatch(/missing <file> argument/);
|
|
});
|
|
});
|
|
|
|
// root-only: the uid rule under the production runtime (start.sh runs as
|
|
// root, so operator.env MUST be uid-0 owned; nobody-owned 0600 is refused).
|
|
const rootSuite = IS_ROOT ? describe : describe.skip;
|
|
rootSuite('DC-164 ownership under root runtime', () => {
|
|
test('foreign-owned (nobody) 0600 → exit 3', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opflags-sec-'));
|
|
const file = path.join(dir, 'operator.env');
|
|
fs.writeFileSync(file, 'A=1\n');
|
|
fs.chmodSync(file, 0o600);
|
|
fs.chownSync(file, 65534, 65534);
|
|
try {
|
|
const r = runNode(file);
|
|
expect(r.code).toBe(3);
|
|
expect(r.err).toMatch(/owned by uid 65534/);
|
|
} finally {
|
|
try { fs.chownSync(file, 0, 0); } catch (_) {}
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|