From 13be9747e43e925feddd8f5ead3c2a4cae7a586b Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 14 Mar 2026 13:56:43 -0400 Subject: [PATCH] Security reivew --- docs/config-generator-plan.md | 164 ----------------------------- docs/static/js/config-generator.js | 68 +++++++----- 2 files changed, 44 insertions(+), 188 deletions(-) delete mode 100644 docs/config-generator-plan.md diff --git a/docs/config-generator-plan.md b/docs/config-generator-plan.md deleted file mode 100644 index 5fcd85e9..00000000 --- a/docs/config-generator-plan.md +++ /dev/null @@ -1,164 +0,0 @@ -# Config Generator for ntfy Docs - -## Context - -The ntfy config page (`docs/config.md`) documents 60+ server configuration options across many sections. Users often struggle to assemble a working config file. An interactive config generator helps users build a `server.yml`, `docker-compose.yml`, or env vars file by answering guided questions, with real-time preview. - -## Files Created - -| File | Purpose | -|------|---------| -| `docs/static/js/config-generator.js` | All generator logic (vanilla JS, ~300 lines) | -| `docs/static/css/config-generator.css` | All generator styles (~250 lines) | - -## Files Modified - -| File | Change | -|------|--------| -| `docs/config.md` | Inserted `## Config generator` HTML block at line 137 (before `## Database options`) | -| `mkdocs.yml` | Added `config-generator.js` to `extra_javascript` and `config-generator.css` to `extra_css` | - -## UI Layout - -Two-panel side-by-side layout: - -- **Left panel**: Collapsible accordion sections with form inputs for config options -- **Right panel** (sticky): Tabs for `server.yml` / `docker-compose.yml` / `Environment variables`, with a copy button. Updates in real-time as the user changes options. -- **Responsive**: Stacks vertically on screens < 900px - -## Configuration Sections (Left Panel) - -### 1. Basic Setup -- `base-url` (text, placeholder: `https://ntfy.example.com`) -- `listen-http` (text, placeholder: `:80`) -- `behind-proxy` (checkbox) - -### 2. Database -- Radio: SQLite (default) vs PostgreSQL -- SQLite → `cache-file` (text, placeholder: `/var/cache/ntfy/cache.db`) -- PostgreSQL → `database-url` (text, placeholder: `postgres://user:pass@host:5432/ntfy`) - -### 3. Access Control -- "Enable access control" (checkbox) → shows: - - `auth-file` (text, placeholder: `/var/lib/ntfy/auth.db`) — hidden if PostgreSQL - - `auth-default-access` (select: `read-write`, `read-only`, `write-only`, `deny-all`) - - `enable-login` (checkbox) - - `enable-signup` (checkbox) - - Provisioned users (repeatable rows): username, password hash, role (admin/user) - - Provisioned ACLs (repeatable rows): username, topic pattern, permission (rw/ro/wo/deny) - - Provisioned tokens (repeatable rows): username, token, label - -### 4. Attachments -- "Enable attachments" (checkbox) → shows: - - `attachment-cache-dir` (text, placeholder: `/var/cache/ntfy/attachments`) - - `attachment-file-size-limit` (text, placeholder: `15M`) - - `attachment-total-size-limit` (text, placeholder: `5G`) - - `attachment-expiry-duration` (text, placeholder: `3h`) - -### 5. Message Cache -- `cache-duration` (text, placeholder: `12h`) - -### 6. Web Push -- "Enable web push" (checkbox) → shows: - - `web-push-public-key` (text) - - `web-push-private-key` (text) - - `web-push-file` (text, placeholder: `/var/lib/ntfy/webpush.db`) — hidden if PostgreSQL - - `web-push-email-address` (text) - -### 7. Email Notifications (Outgoing) -- "Enable email sending" (checkbox) → shows: - - `smtp-sender-addr` (text, placeholder: `smtp.example.com:587`) - - `smtp-sender-from` (text, placeholder: `ntfy@example.com`) - - `smtp-sender-user` (text) - - `smtp-sender-pass` (text, type=password) - -### 8. Email Publishing (Incoming) -- "Enable email publishing" (checkbox) → shows: - - `smtp-server-listen` (text, placeholder: `:25`) - - `smtp-server-domain` (text, placeholder: `ntfy.example.com`) - - `smtp-server-addr-prefix` (text, placeholder: `ntfy-`) - -### 9. Upstream Server -- "iOS users will use this server" (checkbox) → sets `upstream-base-url: https://ntfy.sh` - -### 10. Monitoring -- "Enable Prometheus metrics" (checkbox) → sets `enable-metrics: true` - -## JavaScript Architecture (`config-generator.js`) - -Single vanilla JS file, no dependencies. Key structure: - -1. **Config definitions array (`CONFIG`)**: Each entry has `key`, `env`, `type`, `def`, `section`. This is the single source of truth for all three output formats. - -2. **`collectValues()`**: Reads all form inputs, returns an object of key→value, filtering out empty values. Handles repeatable rows for auth-users/access/tokens. Respects conditional visibility (skips hidden fields). - -3. **`generateServerYml(values)`**: Outputs YAML with section comments. Provisioned users/ACLs/tokens rendered as YAML arrays. - -4. **`generateDockerCompose(values)`**: Wraps values as env vars in Docker Compose format. Key transformations: - - Uses `NTFY_` prefixed env var names from config definitions - - File paths adjusted for Docker via `DOCKER_PATH_MAP` - - `$` doubled to `$$` in bcrypt hashes (with comment) - - Standard boilerplate: `services:`, `image:`, `volumes:`, `ports:`, `command: serve` - - Provisioned users/ACLs/tokens use indexed env vars (e.g., `NTFY_AUTH_USERS_0_USERNAME`) - -5. **`generateEnvVars(values)`**: Simple `export NTFY_KEY="value"` format. Single quotes for values containing `$`. - -6. **`updateOutput()`**: Called on every input change. Runs the active tab's generator and updates the `` element. - -7. **`initGenerator()`**: Called on DOMContentLoaded, no-ops if `#config-generator` is missing. Sets up event listeners for tabs, accordions, repeatable row add/remove, conditional toggles, and special checkboxes (upstream, metrics). - -### Conditional visibility -- Database = PostgreSQL → hide `cache-file`, `auth-file`, `web-push-file`; show `database-url` -- Access control unchecked → hide all auth fields -- Each "enable X" checkbox controls visibility of its section's detail fields via `data-toggle` attribute - -### Docker Compose path mapping -- `/var/cache/ntfy/cache.db` → `/var/lib/ntfy/cache.db` -- `/var/cache/ntfy/attachments` → `/var/lib/ntfy/attachments` -- `/var/lib/ntfy/*` → unchanged -- Volume: `./:/var/lib/ntfy` - -## CSS Architecture (`config-generator.css`) - -Key aspects: - -- **Flex layout** with `gap: 24px` for the two-panel layout -- **Sticky right panel**: `position: sticky; top: 76px; align-self: flex-start; max-height: calc(100vh - 100px); overflow-y: auto` -- **Dark mode**: Uses `body[data-md-color-scheme="slate"]` selectors (matching existing pattern in `extra.css`) -- **Accent color**: Uses `var(--md-primary-fg-color)` (`#338574`) for focus states, active tabs, and interactive elements -- **Responsive**: `@media (max-width: 900px)` → column layout, static right panel -- **Accordion**: `.cg-section-header` click toggles `.cg-section.open` which shows/hides `.cg-section-body` -- **Tabs**: `.cg-tab.active` gets accent color border-bottom highlight -- **Code output**: Dark background (`#1e1e1e`) with light text, consistent in both light and dark mode - -## HTML Block (in config.md) - -~250 lines of HTML inserted at line 137. Pure HTML (no markdown inside). Structure: - -``` -## Config generator -
-
- -
-
- -
-
-``` - -## Verification - -The docs build was verified with `mkdocs build` — completed successfully with no errors. The generated HTML at `server/docs/config/index.html` contains the config-generator elements. - -Manual verification checklist: -1. `cd ntfy && mkdocs serve` — open config page in browser -2. Verify all 10 sections expand/collapse -3. Fill in basic setup → check server.yml output updates -4. Switch tabs → verify docker-compose.yml and env vars render correctly -5. Toggle access control → verify auth fields show/hide -6. Add provisioned users/ACLs/tokens → verify repeatable rows work -7. Switch database to PostgreSQL → verify SQLite fields hide -8. Toggle dark mode → verify styles look correct -9. Resize to mobile width → verify column layout -10. Click copy button → verify clipboard content diff --git a/docs/static/js/config-generator.js b/docs/static/js/config-generator.js index 3efcb7fd..48c23db3 100644 --- a/docs/static/js/config-generator.js +++ b/docs/static/js/config-generator.js @@ -317,7 +317,7 @@ } if (c.section === "auth") hadAuth = true; const val = values[c.key]; - lines.push(c.type === "bool" ? `${c.key}: true` : `${c.key}: "${val}"`); + lines.push(c.type === "bool" ? `${c.key}: true` : `${c.key}: "${escapeYamlValue(val)}"`); }); // Find insertion point for auth-users/auth-access/auth-tokens: @@ -347,7 +347,7 @@ hadAuth = true; } authExtra.push("auth-users:"); - users.forEach((entry) => authExtra.push(` - "${entry}"`)); + users.forEach((entry) => authExtra.push(` - "${escapeYamlValue(entry)}"`)); } const acls = formatAuthAcls(values); @@ -358,7 +358,7 @@ hadAuth = true; } authExtra.push("auth-access:"); - acls.forEach((entry) => authExtra.push(` - "${entry}"`)); + acls.forEach((entry) => authExtra.push(` - "${escapeYamlValue(entry)}"`)); } const tokens = formatAuthTokens(values); @@ -369,7 +369,7 @@ hadAuth = true; } authExtra.push("auth-tokens:"); - tokens.forEach((entry) => authExtra.push(` - "${entry}"`)); + tokens.forEach((entry) => authExtra.push(` - "${escapeYamlValue(entry)}"`)); } // Splice auth extras into the right position @@ -397,7 +397,7 @@ val = val.replace(/\$/g, "$$$$"); hasDollarNote = true; } - lines.push(` ${c.env}: "${val}"`); + lines.push(` ${c.env}: "${escapeYamlValue(val)}"`); }); const users = formatAuthUsers(values); @@ -405,17 +405,17 @@ let usersVal = users.join(","); usersVal = usersVal.replace(/\$/g, "$$$$"); hasDollarNote = true; - lines.push(` NTFY_AUTH_USERS: "${usersVal}"`); + lines.push(` NTFY_AUTH_USERS: "${escapeYamlValue(usersVal)}"`); } const acls = formatAuthAcls(values); if (acls) { - lines.push(` NTFY_AUTH_ACCESS: "${acls.join(",")}"`); + lines.push(` NTFY_AUTH_ACCESS: "${escapeYamlValue(acls.join(","))}"`); } const tokens = formatAuthTokens(values); if (tokens) { - lines.push(` NTFY_AUTH_TOKENS: "${tokens.join(",")}"`); + lines.push(` NTFY_AUTH_TOKENS: "${escapeYamlValue(tokens.join(","))}"`); } if (hasDollarNote) { @@ -444,25 +444,22 @@ CONFIG.forEach((c) => { if (!(c.key in values)) return; const val = c.type === "bool" ? "true" : values[c.key]; - const q = val.includes("$") ? "'" : "\""; - lines.push(`${c.env}=${q}${val}${q}`); + lines.push(`${c.env}=${escapeShellValue(val)}`); }); const users = formatAuthUsers(values); if (users) { - const usersStr = users.join(","); - const q = usersStr.includes("$") ? "'" : "\""; - lines.push(`NTFY_AUTH_USERS=${q}${usersStr}${q}`); + lines.push(`NTFY_AUTH_USERS=${escapeShellValue(users.join(","))}`); } const acls = formatAuthAcls(values); if (acls) { - lines.push(`NTFY_AUTH_ACCESS="${acls.join(",")}"`); + lines.push(`NTFY_AUTH_ACCESS=${escapeShellValue(acls.join(","))}`); } const tokens = formatAuthTokens(values); if (tokens) { - lines.push(`NTFY_AUTH_TOKENS="${tokens.join(",")}"`); + lines.push(`NTFY_AUTH_TOKENS=${escapeShellValue(tokens.join(","))}`); } return lines.join("\n"); @@ -640,11 +637,17 @@ // --- Helpers --- + function secureRandomInt(max) { + const arr = new Uint32Array(1); + crypto.getRandomValues(arr); + return arr[0] % max; + } + function generateToken() { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let token = "tk_"; for (let i = 0; i < 29; i++) { - token += chars.charAt(Math.floor(Math.random() * chars.length)); + token += chars.charAt(secureRandomInt(chars.length)); } return token; } @@ -653,11 +656,28 @@ const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let password = ""; for (let i = 0; i < 16; i++) { - password += chars.charAt(Math.floor(Math.random() * chars.length)); + password += chars.charAt(secureRandomInt(chars.length)); } return password; } + function escapeHtml(str) { + return str.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); + } + + function escapeYamlValue(str) { + return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + } + + function escapeShellValue(val) { + // Use single quotes for values with $, double quotes otherwise + // Escape the chosen quote character within the value + if (val.includes("$")) { + return "'" + val.replace(/'/g, "'\\''") + "'"; + } + return '"' + val.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; + } + function prefill(modal, key, value) { const el = modal.querySelector(`[data-key="${key}"]`); if (el && !el.value.trim() && !el.dataset.cleared) el.value = value; @@ -898,14 +918,14 @@ row.className = `cg-repeatable-row cg-auth-${type}-row`; if (type === "user") { - const username = `newuser${Math.floor(Math.random() * 100) + 1}`; + const username = `newuser${secureRandomInt(100) + 1}`; row.innerHTML = - `` + - `` + + `` + + `` + "" + ""; } else if (type === "acl") { - let aclUser = `someuser${Math.floor(Math.random() * 100) + 1}`; + let aclUser = `someuser${secureRandomInt(100) + 1}`; const modal = container.closest(".cg-modal"); if (modal) { const userRows = modal.querySelectorAll(".cg-auth-user-row"); @@ -919,7 +939,7 @@ } } row.innerHTML = - `` + + `` + "" + "" + ""; @@ -932,8 +952,8 @@ if (name && name.value.trim()) tokenUser = name.value.trim(); } row.innerHTML = - `` + - `` + + `` + + `` + "" + ""; }