diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index baa42d6f..d7a909de 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,6 +46,7 @@ jobs: sudo bastille cmd node sh -lc 'su -l indiekit -c "cd /usr/local/indiekit && if ! (grep -Eq \"^SECRET=.*\" .env && grep -Eq \"^PASSWORD_SECRET=.*\" .env); then echo \"Missing SECRET or PASSWORD_SECRET in /usr/local/indiekit/.env\"; exit 1; fi"' # Validate startup prerequisites before touching the running service. + sudo bastille cmd node sh -lc 'su -l indiekit -c "cd /usr/local/indiekit && NODE_ENV=production node scripts/preflight-production-security.mjs"' sudo bastille cmd node sh -lc 'su -l indiekit -c "cd /usr/local/indiekit && NODE_ENV=production node scripts/preflight-mongo-connection.mjs"' # Restart asynchronously to avoid hanging SSH sessions when rc scripts keep stdout open. @@ -67,6 +68,7 @@ jobs: echo "Indiekit process not found after restart." sudo bastille cmd node sh -lc "tail -n 120 ${restart_log} || true" sudo bastille cmd node sh -lc 'service indiekit onestatus || true' + sudo bastille cmd node sh -lc 'su -l indiekit -c "cd /usr/local/indiekit && NODE_ENV=production node scripts/preflight-production-security.mjs" || true' sudo bastille cmd node sh -lc 'su -l indiekit -c "cd /usr/local/indiekit && NODE_ENV=production node scripts/preflight-mongo-connection.mjs" || true' exit 1 # Optionally reload nginx on web jail diff --git a/README.md b/README.md index 0c87114c..7f17cf9e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,9 @@ - When `INDIEKIT_ADMIN_URL` is set, config wires absolute auth endpoints/callback base (`/auth`, `/auth/token`, `/auth/introspect`) to that URL to keep login redirects on `/admin/*`. - Login uses `PASSWORD_SECRET` (bcrypt hash), not `INDIEKIT_PASSWORD`. - If no `PASSWORD_SECRET` exists yet, open `/admin/auth/new-password` once to generate it. +- If login appears passwordless, first check for an existing authenticated session cookie. Use `/session/logout` (or `/admin/session/logout` behind proxy) to force a fresh login challenge. +- Upstream IndieKit auto-authenticates in dev mode (`NODE_ENV=development`). This repository patches that behavior so dev auto-auth only works when `INDIEKIT_ALLOW_DEV_AUTH=1` is explicitly set. +- Production startup now fails closed when auth/session settings are unsafe (`NODE_ENV` not `production`, `INDIEKIT_ALLOW_DEV_AUTH=1`, weak `SECRET`, missing/invalid `PASSWORD_SECRET`, or empty-password hash). - Post management UI should use `/posts` (`@indiekit/endpoint-posts.mountPath`). - Do not set post-management `mountPath` to frontend routes like `/blog`, otherwise backend publishing can be shadowed by the public site. @@ -68,11 +71,13 @@ - `start.sh` is intentionally ignored by Git (`.gitignore`) so server secrets are not committed. - Use `start.example.sh` as the tracked template and keep real credentials in environment variables (or `.env` on the server). - Startup scripts parse `.env` with the `dotenv` parser (not shell `source`), so values containing spaces are handled safely. -- Startup scripts run preflight + patch helpers before boot (`scripts/preflight-mongo-connection.mjs`, `scripts/patch-lightningcss.mjs`, `scripts/patch-endpoint-media-scope.mjs`, `scripts/patch-endpoint-media-sharp-runtime.mjs`, `scripts/patch-frontend-sharp-runtime.mjs`, `scripts/patch-endpoint-files-upload-route.mjs`, `scripts/patch-endpoint-files-upload-locales.mjs`, `scripts/patch-frontend-serviceworker-file.mjs`, `scripts/patch-conversations-collection-guards.mjs`). +- Startup scripts run preflight + patch helpers before boot (`scripts/preflight-production-security.mjs`, `scripts/preflight-mongo-connection.mjs`, `scripts/patch-lightningcss.mjs`, `scripts/patch-endpoint-media-scope.mjs`, `scripts/patch-endpoint-media-sharp-runtime.mjs`, `scripts/patch-frontend-sharp-runtime.mjs`, `scripts/patch-endpoint-files-upload-route.mjs`, `scripts/patch-endpoint-files-upload-locales.mjs`, `scripts/patch-frontend-serviceworker-file.mjs`, `scripts/patch-conversations-collection-guards.mjs`, `scripts/patch-indieauth-devmode-guard.mjs`). +- The production security preflight blocks startup on insecure auth/session configuration and catches empty-password bcrypt hashes. - The media scope patch fixes a known upstream issue where file uploads can fail if the token scope is `create update delete` without explicit `media`. - The media sharp runtime patch makes image transformation resilient on FreeBSD: if `sharp` cannot load, uploads continue without resize/rotation instead of crashing the server process. - The frontend sharp runtime patch makes icon generation non-fatal on FreeBSD when `sharp` cannot load, preventing startup crashes in asset controller imports. - The files upload route patch fixes browser multi-upload by posting to `/files/upload` (session-authenticated) instead of direct `/media` calls without bearer token. - The files upload locale patch adds missing `files.upload.dropText`/`files.upload.browse`/`files.upload.submitMultiple` labels in endpoint locale files so UI text does not render raw translation keys. - The frontend serviceworker patch ensures `@indiekit/frontend/lib/serviceworker.js` exists at runtime to avoid ENOENT in the offline/service worker route. -- The conversations guard patch prevents `Cannot read properties of undefined (reading 'find')` when the `conversation_items` collection is temporarily unavailable. \ No newline at end of file +- The conversations guard patch prevents `Cannot read properties of undefined (reading 'find')` when the `conversation_items` collection is temporarily unavailable. +- The indieauth dev-mode guard patch prevents accidental production auth bypass by requiring explicit `INDIEKIT_ALLOW_DEV_AUTH=1` to enable dev auto-login. diff --git a/package.json b/package.json index ea9b89d0..9b03ea31 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "postinstall": "node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-conversations-collection-guards.mjs", - "serve": "node scripts/preflight-mongo-connection.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-conversations-collection-guards.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", + "postinstall": "node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indieauth-devmode-guard.mjs", + "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/scripts/patch-indieauth-devmode-guard.mjs b/scripts/patch-indieauth-devmode-guard.mjs new file mode 100644 index 00000000..67127567 --- /dev/null +++ b/scripts/patch-indieauth-devmode-guard.mjs @@ -0,0 +1,57 @@ +import { access, readFile, writeFile } from "node:fs/promises"; + +const candidates = [ + "node_modules/@indiekit/indiekit/lib/indieauth.js", +]; + +const oldCode = `if (devMode) { + request.session.access_token = process.env.NODE_ENV; + request.session.scope = "create update delete media"; + } else if (!process.env.PASSWORD_SECRET) {`; + +const newCode = `if (devMode && process.env.INDIEKIT_ALLOW_DEV_AUTH === "1") { + request.session.access_token = process.env.NODE_ENV; + request.session.scope = "create update delete media"; + } else if (!process.env.PASSWORD_SECRET) {`; + +async function exists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +let checked = 0; +let patched = 0; + +for (const filePath of candidates) { + if (!(await exists(filePath))) { + continue; + } + + checked += 1; + + const source = await readFile(filePath, "utf8"); + + if (source.includes(newCode)) { + continue; + } + + if (!source.includes(oldCode)) { + continue; + } + + const updated = source.replace(oldCode, newCode); + await writeFile(filePath, updated, "utf8"); + patched += 1; +} + +if (checked === 0) { + console.log("[postinstall] No indieauth middleware files found"); +} else if (patched === 0) { + console.log("[postinstall] indieauth dev-mode guard already patched"); +} else { + console.log(`[postinstall] Patched indieauth dev-mode guard in ${patched} file(s)`); +} diff --git a/scripts/preflight-production-security.mjs b/scripts/preflight-production-security.mjs new file mode 100644 index 00000000..7a871cf3 --- /dev/null +++ b/scripts/preflight-production-security.mjs @@ -0,0 +1,66 @@ +import "dotenv/config"; + +import bcrypt from "bcrypt"; + +const strictMode = process.env.REQUIRE_SECURITY !== "0"; +const nodeEnv = (process.env.NODE_ENV || "").toLowerCase(); + +function failOrWarn(message) { + if (strictMode) { + console.error(message); + process.exit(1); + } + + console.warn(`${message} Continuing because strict mode is disabled.`); +} + +if (nodeEnv !== "production") { + failOrWarn( + `[preflight] NODE_ENV must be "production" for secure startup (received "${process.env.NODE_ENV || "(unset)"}").`, + ); +} + +if (process.env.INDIEKIT_ALLOW_DEV_AUTH === "1") { + failOrWarn( + "[preflight] INDIEKIT_ALLOW_DEV_AUTH=1 is not allowed in production.", + ); +} + +const secret = process.env.SECRET || ""; +if (secret.length < 32) { + failOrWarn( + "[preflight] SECRET must be set and at least 32 characters long.", + ); +} + +const passwordSecret = process.env.PASSWORD_SECRET || ""; +if (!passwordSecret) { + failOrWarn("[preflight] PASSWORD_SECRET is required."); +} + +if (!/^\$2[aby]\$\d{2}\$/.test(passwordSecret)) { + failOrWarn( + "[preflight] PASSWORD_SECRET must be a bcrypt hash (starts with $2a$, $2b$, or $2y$).", + ); +} + +try { + const emptyPasswordValid = await bcrypt.compare("", passwordSecret); + if (emptyPasswordValid) { + failOrWarn( + "[preflight] PASSWORD_SECRET matches an empty password. Generate a non-empty password hash via /auth/new-password.", + ); + } +} catch (error) { + failOrWarn( + `[preflight] PASSWORD_SECRET could not be validated with bcrypt: ${error.message}`, + ); +} + +if (process.env.INDIEKIT_PASSWORD) { + console.warn( + "[preflight] INDIEKIT_PASSWORD is set but ignored by core auth. Use PASSWORD_SECRET only.", + ); +} + +console.log("[preflight] Production auth configuration OK"); diff --git a/start.example.sh b/start.example.sh index 4f1eca9a..86080437 100644 --- a/start.example.sh +++ b/start.example.sh @@ -37,6 +37,9 @@ export NODE_ENV="production" export INDIEKIT_DEBUG="0" unset DEBUG +# Verify production auth/session hardening before launching server. +/usr/local/bin/node scripts/preflight-production-security.mjs + # Verify MongoDB credentials/connectivity before launching server. /usr/local/bin/node scripts/preflight-mongo-connection.mjs @@ -49,5 +52,6 @@ unset DEBUG /usr/local/bin/node scripts/patch-endpoint-files-upload-locales.mjs /usr/local/bin/node scripts/patch-frontend-serviceworker-file.mjs /usr/local/bin/node scripts/patch-conversations-collection-guards.mjs +/usr/local/bin/node scripts/patch-indieauth-devmode-guard.mjs exec /usr/local/bin/node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs