// Credential references (note section 2, addendum A section 7). A // reference names where a token lives, never the token. The checks here use // lstat and realpath only; nothing in this package opens a token file. import { lstatSync, realpathSync } from "node:fs"; import { isAbsolute, normalize, relative, sep } from "node:path"; import { refuse } from "./errors.mjs"; import { SERVICES } from "./vocabulary.mjs"; import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs"; const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/; const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" }); const WARN_DAYS = 7; const DAY_MS = 24 * 60 * 60 * 1000; // Shape check for one reference. Returns a frozen { service, file | env, // rotateBy | expires }. export function parseCredentialRef(ref, service, where) { if (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`); requireObject(ref, where); const dateKey = DATE_KEY[service]; rejectUnknownKeys(ref, ["file", "env", dateKey], where); const hasFile = ref.file !== undefined; const hasEnv = ref.env !== undefined; if (hasFile === hasEnv) refuse(`${where} must name exactly one of "file" or "env"`); const out = { service }; if (hasFile) { if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) { refuse(`${where}.file must be a normalized absolute path`); } out.file = ref.file; } else { if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`); out.env = ref.env; } if (ref[dateKey] === undefined) refuse(`${where} needs "${dateKey}" (YYYY-MM-DD)`); out[dateKey] = requireDate(ref[dateKey], `${where}.${dateKey}`); return Object.freeze(out); } function inside(root, path) { const rel = relative(root, path); return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); } // Check a parsed reference against the filesystem and the calendar. // Returns { problems: [...], warnings: [...] }; a caller that finds any // problem refuses. `forbiddenRoots` are directories a token file must not // sit in (the repository, dataRoot). `now` is a Date. export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), env = process.env, uid = process.getuid() } = {}) { const problems = []; const warnings = []; const label = `${ref.service} ${ref.file ? `file ${ref.file}` : `env ${ref.env}`}`; if (ref.file) { let stat = null; try { stat = lstatSync(ref.file); } catch { problems.push(`${label}: not found`); } if (stat) { if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`); else { if (stat.uid !== uid) problems.push(`${label}: owned by uid ${stat.uid}, not ${uid}`); if ((stat.mode & 0o077) !== 0) problems.push(`${label}: mode ${(stat.mode & 0o777).toString(8)} gives group or other access; use 600`); if (stat.size === 0) problems.push(`${label}: empty`); let real = ref.file; try { real = realpathSync(ref.file); } catch { problems.push(`${label}: path can't be resolved`); } for (const root of forbiddenRoots) { let realRoot = root; try { realRoot = realpathSync(root); } catch { // A root that doesn't exist yet can't contain the file. } if (inside(realRoot, real)) problems.push(`${label}: inside ${root}; token files live outside the repository and dataRoot`); } } } } else if (env[ref.env] === undefined || env[ref.env] === "") { warnings.push(`${label}: not set in this environment; the launcher must provide it`); } const days = (dateText) => Math.floor((Date.parse(`${dateText}T00:00:00Z`) - now.getTime()) / DAY_MS); if (ref.expires) { const left = days(ref.expires); if (Date.parse(`${ref.expires}T00:00:00Z`) <= now.getTime()) problems.push(`${label}: expired on ${ref.expires}`); else if (left < WARN_DAYS) warnings.push(`${label}: expires on ${ref.expires}`); } if (ref.rotateBy && Date.parse(`${ref.rotateBy}T00:00:00Z`) <= now.getTime()) { warnings.push(`${label}: rotation was due on ${ref.rotateBy}`); } return { problems, warnings }; }