From 7e45780766f0dfa01182cc04165d4afd3d97eae5 Mon Sep 17 00:00:00 2001 From: jherforth Date: Sun, 25 Oct 2026 11:07:55 -0400 Subject: [PATCH] fix(security): harden the admin PIN and refuse cross-site writes (mrramam/HomeGlow#62, #73) The admin PIN is tamper resistance for a household, and the README now says so plainly: HomeGlow is local-only, not secure, and not for the internet. - The PIN is stored as scrypt with a random salt and compared in constant time. An old unsalted SHA-256 is rehashed on its next correct entry. All PIN checks live in utils/adminPin.js. - Five wrong PINs, then a wait that doubles from 30 seconds to five minutes, even for the right PIN. One limit for every route that checks it (429 with Retry-After). - Staging a restore (file or Drive) and a rollback need the PIN, like a backup download already did. Cancel and restart don't: they can only drop or apply what the PIN allowed. - A write whose Origin is another website is refused (403), judged by Sec-Fetch-Site, or by host for browsers without it. CORS stopped other sites reading the API; this stops them posting to it. - The backup dialogs share one PIN prompt (useAdminPinPrompt), and every PIN prompt says how long to wait when locked (en, es). - Tests: the admin PIN routes on a real server (#73), the hashing and the limit, and the cross-site check. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +- client/src/components/AdminPanel.jsx | 5 +- .../src/components/BackupRestoreSection.jsx | 52 +++--- client/src/components/ChoreWidget.jsx | 8 +- .../components/GoogleDriveBackupSection.jsx | 57 +++--- client/src/hooks/useAdminPinPrompt.js | 59 +++++++ client/src/i18n/locales/en/admin.json | 2 + client/src/i18n/locales/en/common.json | 3 +- client/src/i18n/locales/es/admin.json | 2 + client/src/i18n/locales/es/common.json | 3 +- client/src/utils/pinErrors.js | 11 ++ client/src/utils/pinErrors.test.js | 22 +++ docs/README.md | 8 +- docs/architecture/database.md | 2 +- docs/reference/backend-api.md | 13 +- docs/reference/configuration.md | 13 +- server/index.js | 34 ++-- server/routes/driveBackup.js | 17 +- server/routes/haPanels.js | 11 +- server/routes/localBackup.js | 21 ++- server/tests/adminPin.test.js | 95 ++++++++++ server/tests/adminPinRoutes.test.js | 164 ++++++++++++++++++ server/tests/crossSiteWrites.test.js | 35 ++++ server/utils/adminPin.js | 121 +++++++++++-- server/utils/crossSiteWrites.js | 47 +++++ 25 files changed, 683 insertions(+), 133 deletions(-) create mode 100644 client/src/hooks/useAdminPinPrompt.js create mode 100644 client/src/utils/pinErrors.js create mode 100644 client/src/utils/pinErrors.test.js create mode 100644 server/tests/adminPin.test.js create mode 100644 server/tests/adminPinRoutes.test.js create mode 100644 server/tests/crossSiteWrites.test.js create mode 100644 server/utils/crossSiteWrites.js diff --git a/README.md b/README.md index 2c1da820..5764a1c1 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,18 @@ Create HTML widgets with theme support. See `server/widgets/README.md` for detai ## 🔒 Security +**HomeGlow is not secure, and it is not designed to be exposed to the internet.** It's a local-only app for a trusted home network. It has no logins or user accounts, so any device that can reach it can read and change its data. + +- **Keep it on your home network.** Don't port-forward it or publish it. To reach it away from home, connect to your network with a VPN (WireGuard, Tailscale). +- **The admin PIN is tamper resistance, not access control.** It keeps household members who shouldn't make changes, such as children at a wall display or guests, out of the Admin Panel. It doesn't stop someone on your network who knows how to call HomeGlow's API directly. +- **What HomeGlow does enforce:** + - downloading a backup, restoring one, and Home Assistant unlock actions need the PIN on the server too; + - wrong PINs are rate-limited; + - other websites can't use a browser on your network to read or change HomeGlow's data. + DISCLAIMER: This project uses AI in its development process. While you are right to generally be skeptical about AI coding security, this project doesn't have any secure endpoints or credentials for AI to mishandle. Because there is no security implicitly or explicitly provided by this project, there cannot be any security failures from AI. When in doubt, don't expose services to the internet. -**Reverse Proxy**: Use SSL terminators/Cloudflare for HTTPS and custom domains +**Reverse Proxy**: A reverse proxy can add HTTPS and a custom name on your network. If yours reaches the internet (a Cloudflare Tunnel, for example), put a login in front of HomeGlow there, such as Cloudflare Access, because HomeGlow has none of its own. ## 🗂️ Data diff --git a/client/src/components/AdminPanel.jsx b/client/src/components/AdminPanel.jsx index f5304606..b110a03e 100644 --- a/client/src/components/AdminPanel.jsx +++ b/client/src/components/AdminPanel.jsx @@ -80,6 +80,7 @@ import HomeAssistantPanels from './HomeAssistantPanels'; import ThemeLibrary from './ThemeLibrary'; import axios from 'axios'; import { API_BASE_URL } from '../utils/apiConfig.js'; +import { pinErrorText } from '../utils/pinErrors.js'; import { revealTabPicker } from '../utils/revealTabPicker.js'; import { getDeviceApiBase, getDeviceName, setDeviceName, isValidDeviceName } from '../utils/deviceName.js'; import { fetchPinRemembered, setPinRemembered, forgetPinOnAllDevices, shouldPromptForPin } from '../utils/adminPinDevice.js'; @@ -1443,7 +1444,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ const response = await axios.post(`${API_BASE_URL}/api/admin-pin/verify`, { pin }); valid = response.data?.valid === true; } catch (error) { - throw new Error(error.response?.data?.error || t('admin:vacation.pinWrong')); + throw new Error(pinErrorText(error, t, t('admin:vacation.pinWrong'))); } if (!valid) throw new Error(t('admin:vacation.pinWrong')); setVacationPinOpen(false); @@ -1847,7 +1848,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ } } } catch (error) { - throw new Error(error.response?.data?.error || 'Invalid PIN. Please try again.'); + throw new Error(pinErrorText(error, t, 'Invalid PIN. Please try again.')); } }; diff --git a/client/src/components/BackupRestoreSection.jsx b/client/src/components/BackupRestoreSection.jsx index ed007450..ef0ea5a2 100644 --- a/client/src/components/BackupRestoreSection.jsx +++ b/client/src/components/BackupRestoreSection.jsx @@ -8,6 +8,7 @@ import { Download, Upload } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; import { API_BASE_URL } from '../utils/apiConfig.js'; import PinModal from './PinModal'; +import { useAdminPinPrompt } from '../hooks/useAdminPinPrompt.js'; import RestoreStatusAlerts from './RestoreStatusAlerts.jsx'; import GoogleDriveBackupSection from './GoogleDriveBackupSection.jsx'; @@ -55,10 +56,12 @@ export default function BackupRestoreSection({ googleConnected = false }) { const [message, setMessage] = useState(null); const [busy, setBusy] = useState(false); const [restarting, setRestarting] = useState(false); - const [exportPinOpen, setExportPinOpen] = useState(false); const [chosenFile, setChosenFile] = useState(null); const [uploadProgress, setUploadProgress] = useState(null); const [confirmRollback, setConfirmRollback] = useState(false); + // Downloading a backup, restoring one and rolling back ask for the admin + // PIN when one is set; the server checks it too. + const { ask: askPin, pinModalProps } = useAdminPinPrompt({ wrongPin: t('admin:backup.wrongPin') }); const loadStatus = useCallback(async () => { try { @@ -79,40 +82,21 @@ export default function BackupRestoreSection({ googleConnected = false }) { saveBlob(res.data, filenameFrom(res.headers['content-disposition'])); }; - const startExport = async () => { + const runExport = async (pin) => { setMessage(null); setBusy(true); try { - const { data } = await axios.get(`${API_BASE_URL}/api/admin-pin/exists`); - if (data?.exists === true) { - setExportPinOpen(true); - return; - } - await download(); - setMessage({ type: 'success', text: t('admin:backup.exported') }); - } catch (error) { - setMessage({ type: 'error', text: (await readBlobError(error)) || t('admin:backup.exportFailed') }); - } finally { - setBusy(false); - } - }; - - // The PIN dialog: a wrong PIN stays in the dialog; anything else closes it. - const exportWithPin = async (pin) => { - try { - setBusy(true); await download(pin); - setExportPinOpen(false); setMessage({ type: 'success', text: t('admin:backup.exported') }); } catch (error) { - if (error?.response?.status === 403) throw new Error(t('admin:backup.wrongPin')); - setExportPinOpen(false); setMessage({ type: 'error', text: (await readBlobError(error)) || t('admin:backup.exportFailed') }); } finally { setBusy(false); } }; + const startExport = () => askPin(t('admin:backup.pinTitle'), runExport); + // --- import ------------------------------------------------------------------- const chooseFile = (event) => { @@ -121,14 +105,20 @@ export default function BackupRestoreSection({ googleConnected = false }) { if (file) setChosenFile(file); }; - const importFile = async () => { + const confirmImport = () => { const file = chosenFile; setChosenFile(null); + if (file) void askPin(t('admin:backup.pinTitleImport'), (pin) => importFile(file, pin)); + }; + + const importFile = async (file, pin) => { setMessage(null); setBusy(true); setUploadProgress(0); try { const form = new FormData(); + // Before the file: the server checks the PIN before it reads the upload. + if (pin) form.append('pin', pin); form.append('file', file, file.name); await axios.post(`${API_BASE_URL}/api/backup/local/import`, form, { onUploadProgress: (event) => { @@ -173,7 +163,10 @@ export default function BackupRestoreSection({ googleConnected = false }) { const cancel = () => act(() => axios.post(`${API_BASE_URL}/api/backup/drive/restore/cancel`), t('admin:backup.cancelled')); const rollBack = () => { setConfirmRollback(false); - void act(() => axios.post(`${API_BASE_URL}/api/backup/drive/restore/rollback`), t('admin:backup.rollbackStaged')); + void askPin(t('admin:backup.pinTitleRollback'), (pin) => act( + () => axios.post(`${API_BASE_URL}/api/backup/drive/restore/rollback`, pin ? { pin } : {}), + t('admin:backup.rollbackStaged'), + )); }; return ( @@ -248,7 +241,7 @@ export default function BackupRestoreSection({ googleConnected = false }) { - + @@ -263,12 +256,7 @@ export default function BackupRestoreSection({ googleConnected = false }) { - setExportPinOpen(false)} - onVerify={exportWithPin} - title={t('admin:backup.pinTitle')} - /> + ); } diff --git a/client/src/components/ChoreWidget.jsx b/client/src/components/ChoreWidget.jsx index 4709f101..e7a6433d 100644 --- a/client/src/components/ChoreWidget.jsx +++ b/client/src/components/ChoreWidget.jsx @@ -37,6 +37,7 @@ import { useTranslation } from 'react-i18next'; import LoadingBackdrop from './LoadingBackdrop'; import PinModal from './PinModal'; import { API_BASE_URL } from '../utils/apiConfig.js'; +import { pinErrorText } from '../utils/pinErrors.js'; import { getDeviceApiBase } from '../utils/deviceName.js'; import { fetchPinRemembered, setPinRemembered, shouldPromptForPin } from '../utils/adminPinDevice.js'; import { shouldShowChoreToday, getTodayDateString, convertDaysToCrontab, getDueDateStatus, formatDueDate, hasOutstandingBonusChore, countsTowardDailyBonus } from '../utils/choreHelpers.js'; @@ -850,7 +851,12 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { }; const handlePinVerify = async (pin, remember) => { - const response = await axios.post(`${API_BASE_URL}/api/admin-pin/verify`, { pin }); + let response; + try { + response = await axios.post(`${API_BASE_URL}/api/admin-pin/verify`, { pin }); + } catch (error) { + throw new Error(pinErrorText(error, t, 'Incorrect PIN. Please try again.')); + } if (!response.data?.valid) { throw new Error('Incorrect PIN. Please try again.'); } diff --git a/client/src/components/GoogleDriveBackupSection.jsx b/client/src/components/GoogleDriveBackupSection.jsx index 2a44d6fe..bec504f0 100644 --- a/client/src/components/GoogleDriveBackupSection.jsx +++ b/client/src/components/GoogleDriveBackupSection.jsx @@ -7,6 +7,7 @@ import { CloudUpload, Refresh, Schedule, SettingsBackupRestore, Key } from '@mui import axios from 'axios'; import { API_BASE_URL } from '../utils/apiConfig.js'; import PinModal from './PinModal'; +import { useAdminPinPrompt } from '../hooks/useAdminPinPrompt.js'; // Google Drive backup, as part of Backup & restore (BackupRestoreSection, in // System → Connections; shown once a Google account is connected). Nightly or @@ -24,6 +25,10 @@ export default function GoogleDriveBackupSection({ restore = {}, onRestoreChange const [timezone, setTimezone] = useState(''); const [confirmFile, setConfirmFile] = useState(null); + // Staging a restore and downloading a backup's key ask for the admin PIN + // when one is set, even on a display that remembers it; the server checks + // it too. + const { ask: askPin, pinModalProps } = useAdminPinPrompt({ wrongPin: 'Incorrect PIN. Please try again.' }); const fetchConfigAndBackups = useCallback(async () => { setLoading(true); @@ -72,14 +77,17 @@ export default function GoogleDriveBackupSection({ restore = {}, onRestoreChange } }; - const handleConfirmRestore = async () => { + const handleConfirmRestore = () => { const file = confirmFile; setConfirmFile(null); - if (!file) return; + if (file) void askPin('Enter the admin PIN to restore this backup', (pin) => stageRestore(file, pin)); + }; + + const stageRestore = async (file, pin) => { setLoading(true); setMessage(null); try { - await axios.post(`${API_BASE_URL}/api/backup/drive/restore/stage`, { fileId: file.id }); + await axios.post(`${API_BASE_URL}/api/backup/drive/restore/stage`, { fileId: file.id, ...(pin ? { pin } : {}) }); setMessage({ type: 'success', text: `Restore of "${file.name}" is staged and verified. Restart HomeGlow to apply it.` @@ -92,11 +100,6 @@ export default function GoogleDriveBackupSection({ restore = {}, onRestoreChange } }; - // The key unlocks every stored credential, so the server asks for the - // admin PIN (when one is set) before handing it over, even on a device - // that remembers the PIN for the rest of the panel. - const [keyPinOpen, setKeyPinOpen] = useState(false); - const saveBackupKey = async (fileId, pin) => { const res = await axios.post( `${API_BASE_URL}/api/backup/drive/backup-key`, @@ -113,32 +116,17 @@ export default function GoogleDriveBackupSection({ restore = {}, onRestoreChange URL.revokeObjectURL(url); }; - const handleDownloadBackupKey = async () => { + // The key unlocks every stored credential. + const handleDownloadBackupKey = () => { const file = confirmFile; if (!file) return; - try { - const { data } = await axios.get(`${API_BASE_URL}/api/admin-pin/exists`); - if (data?.exists === true) { - setKeyPinOpen(true); - return; + void askPin('Enter the admin PIN to download the encryption key', async (pin) => { + try { + await saveBackupKey(file.id, pin); + } catch (err) { + setMessage({ type: 'error', text: 'That backup does not contain an encryption key, or it could not be downloaded.' }); } - await saveBackupKey(file.id); - } catch (err) { - setMessage({ type: 'error', text: 'That backup does not contain an encryption key, or it could not be downloaded.' }); - } - }; - - const handleKeyPin = async (pin) => { - const file = confirmFile; - if (!file) return; - try { - await saveBackupKey(file.id, pin); - setKeyPinOpen(false); - } catch (err) { - if (err.response?.status === 403) throw new Error('Incorrect PIN. Please try again.'); - setKeyPinOpen(false); - setMessage({ type: 'error', text: 'That backup does not contain an encryption key, or it could not be downloaded.' }); - } + }); }; const formatFileDate = (isoString) => { @@ -267,12 +255,7 @@ export default function GoogleDriveBackupSection({ restore = {}, onRestoreChange - setKeyPinOpen(false)} - onVerify={handleKeyPin} - title="Enter the admin PIN to download the encryption key" - /> + ); } diff --git a/client/src/hooks/useAdminPinPrompt.js b/client/src/hooks/useAdminPinPrompt.js new file mode 100644 index 00000000..3111dd06 --- /dev/null +++ b/client/src/hooks/useAdminPinPrompt.js @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import axios from 'axios'; +import { useTranslation } from 'react-i18next'; +import { API_BASE_URL } from '../utils/apiConfig.js'; +import { pinErrorText } from '../utils/pinErrors.js'; + +/** + * For an action the server itself guards with the admin PIN: a backup + * download, staging a restore or a rollback (server/utils/adminPin.js). + * + * `ask(title, run)` runs `run()` straight away when no PIN is set. Otherwise + * it asks for the PIN, even on a display that remembers it, checks it, then + * closes the dialog and calls `run(pin)`. The action runs after the dialog + * closes, so a long upload or download shows its progress on the page, and + * `run` reports its own failures. + * + * Spread `pinModalProps` onto a PinModal. + */ +export function useAdminPinPrompt({ wrongPin }) { + const { t } = useTranslation('common'); + const [pending, setPending] = useState(null); + + const ask = async (title, run) => { + try { + const { data } = await axios.get(`${API_BASE_URL}/api/admin-pin/exists`); + if (data?.exists === true) { + setPending({ title, run }); + return; + } + } catch { + // The action itself reports what went wrong. + } + await run(); + }; + + const verify = async (pin) => { + let valid = false; + try { + const { data } = await axios.post(`${API_BASE_URL}/api/admin-pin/verify`, { pin }); + valid = data?.valid === true; + } catch (error) { + throw new Error(pinErrorText(error, t, wrongPin)); + } + if (!valid) throw new Error(wrongPin); + const { run } = pending; + setPending(null); + await run(pin); + }; + + return { + ask, + pinModalProps: { + open: Boolean(pending), + title: pending?.title || '', + onClose: () => setPending(null), + onVerify: verify, + }, + }; +} diff --git a/client/src/i18n/locales/en/admin.json b/client/src/i18n/locales/en/admin.json index d6ecc1b5..40d4ddc0 100644 --- a/client/src/i18n/locales/en/admin.json +++ b/client/src/i18n/locales/en/admin.json @@ -821,6 +821,8 @@ "exported": "Backup downloaded.", "exportFailed": "The backup could not be downloaded.", "pinTitle": "Enter the admin PIN to download a backup", + "pinTitleImport": "Enter the admin PIN to restore a backup", + "pinTitleRollback": "Enter the admin PIN to roll back the restore", "wrongPin": "Incorrect PIN. Please try again.", "importHeading": "Restore from a backup", "importHelp": "Choose a backup file (.tar.gz) made here or by Google Drive backup. It is checked first, your current data is saved so you can roll back, and nothing changes until HomeGlow restarts.", diff --git a/client/src/i18n/locales/en/common.json b/client/src/i18n/locales/en/common.json index bdb732f6..cc8fc1f1 100644 --- a/client/src/i18n/locales/en/common.json +++ b/client/src/i18n/locales/en/common.json @@ -39,7 +39,8 @@ }, "pin": { "confirmWithAdminPin": "Confirm with Admin PIN", - "statusCheckFailed": "Could not confirm admin PIN status. Please try again." + "statusCheckFailed": "Could not confirm admin PIN status. Please try again.", + "tooManyTries": "Too many wrong PINs. Try again in {{seconds}} seconds." }, "language": { "label": "Language", diff --git a/client/src/i18n/locales/es/admin.json b/client/src/i18n/locales/es/admin.json index b5b08ffe..fbe2b008 100644 --- a/client/src/i18n/locales/es/admin.json +++ b/client/src/i18n/locales/es/admin.json @@ -821,6 +821,8 @@ "exported": "Copia descargada.", "exportFailed": "No se pudo descargar la copia.", "pinTitle": "Introduce el PIN de administrador para descargar una copia", + "pinTitleImport": "Introduce el PIN de administrador para restaurar una copia", + "pinTitleRollback": "Introduce el PIN de administrador para deshacer la restauración", "wrongPin": "PIN incorrecto. Inténtalo de nuevo.", "importHeading": "Restaurar desde una copia", "importHelp": "Elige un archivo de copia (.tar.gz) hecho aquí o con la copia de Google Drive. Primero se comprueba, tus datos actuales se guardan para poder deshacerlo y nada cambia hasta que HomeGlow se reinicie.", diff --git a/client/src/i18n/locales/es/common.json b/client/src/i18n/locales/es/common.json index 70a53f6f..a46da3fe 100644 --- a/client/src/i18n/locales/es/common.json +++ b/client/src/i18n/locales/es/common.json @@ -39,7 +39,8 @@ }, "pin": { "confirmWithAdminPin": "Confirmar con el PIN de administrador", - "statusCheckFailed": "No se pudo comprobar el estado del PIN. Inténtalo de nuevo." + "statusCheckFailed": "No se pudo comprobar el estado del PIN. Inténtalo de nuevo.", + "tooManyTries": "Demasiados PIN incorrectos. Inténtalo de nuevo en {{seconds}} segundos." }, "language": { "label": "Idioma", diff --git a/client/src/utils/pinErrors.js b/client/src/utils/pinErrors.js new file mode 100644 index 00000000..05ecffc5 --- /dev/null +++ b/client/src/utils/pinErrors.js @@ -0,0 +1,11 @@ +// The message for a refused admin-PIN request. After too many wrong PINs the +// server answers 429 with how long to wait (server/utils/adminPin.js), even +// for the right one. +export function pinErrorText(error, t, fallback) { + const response = error?.response; + if (response?.status === 429) { + const seconds = Number(response.data?.retryAfterSeconds) || Number(response.headers?.['retry-after']) || 30; + return t('common:pin.tooManyTries', { seconds }); + } + return response?.data?.error || fallback; +} diff --git a/client/src/utils/pinErrors.test.js b/client/src/utils/pinErrors.test.js new file mode 100644 index 00000000..e701b9f0 --- /dev/null +++ b/client/src/utils/pinErrors.test.js @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { pinErrorText } from './pinErrors.js'; + +const t = (key, values) => `${key}:${values?.seconds}`; + +describe('pinErrorText', () => { + it('says how long to wait after too many wrong PINs', () => { + const error = { response: { status: 429, data: { retryAfterSeconds: 60 }, headers: {} } }; + expect(pinErrorText(error, t, 'fallback')).toBe('common:pin.tooManyTries:60'); + }); + + it('falls back to Retry-After, then to 30 seconds', () => { + expect(pinErrorText({ response: { status: 429, data: {}, headers: { 'retry-after': '120' } } }, t, 'x')) + .toBe('common:pin.tooManyTries:120'); + expect(pinErrorText({ response: { status: 429 } }, t, 'x')).toBe('common:pin.tooManyTries:30'); + }); + + it("passes the server's own message through, or the fallback", () => { + expect(pinErrorText({ response: { status: 404, data: { error: 'No PIN configured' } } }, t, 'x')).toBe('No PIN configured'); + expect(pinErrorText(new Error('Network Error'), t, 'Incorrect PIN')).toBe('Incorrect PIN'); + }); +}); diff --git a/docs/README.md b/docs/README.md index b5180cb7..25e921c4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -74,8 +74,12 @@ HomeGlow/ - **Two deployable units**: `homeglow-frontend` (Nginx serving the built SPA and proxying `/api`) and `homeglow-backend` (Fastify). They talk over a Docker network; the browser only ever talks to the frontend. -- **No user accounts / auth**: the app has no login. The only access control is an - optional PIN on the Admin Panel. Do not expose it to the public internet. +- **No user accounts / auth**: the app has no login and is built for a trusted home + network only. The optional Admin PIN is tamper resistance: it keeps household + members out of the Admin Panel on a shared display, and the server checks it for + backups and restores, but it doesn't protect the API from another device on the + network. Do not expose HomeGlow to the internet. See the + [security note](../README.md#-security). - **Per-device configuration**: each browser generates a random device name (stored in `localStorage`) and its own tabs, layout, and widget settings live server-side keyed by that device name. diff --git a/docs/architecture/database.md b/docs/architecture/database.md index 4d650a19..daabdbac 100644 --- a/docs/architecture/database.md +++ b/docs/architecture/database.md @@ -147,7 +147,7 @@ issue #105 Phase 2). key (PK), value ``` -**`admin_pin`** — single-row optional PIN hash for the Admin Panel. +**`admin_pin`** — single-row optional PIN hash for the Admin Panel: `scrypt$$`. An unsalted SHA-256 from an older version is rehashed on its next correct entry (`server/utils/adminPin.js`). ``` id (=1), pin_hash, created_at, updated_at ``` diff --git a/docs/reference/backend-api.md b/docs/reference/backend-api.md index 86add801..818608ed 100644 --- a/docs/reference/backend-api.md +++ b/docs/reference/backend-api.md @@ -323,10 +323,11 @@ server-side. | Method | Path | Purpose | | --- | --- | --- | | GET | `/api/connections/homeassistant/status` | `{has_url, has_token, url, weather_entity, encryption}` — never the token. | -| POST | `/api/backup/local/export` | Download a full backup (`.tar.gz`: database, the encryption key in use, uploads, `manifest.json`). Body `{ pin }` when an admin PIN is set (403 otherwise). Not in the demo. | -| POST | `/api/backup/local/import` | Multipart upload of one backup file (up to 4 GB): checked and staged for the next start, the current data saved for rollback → `{ staged }`. 400 for a file that isn't a backup, or one made under a different `ENCRYPTION_KEY`; 409 while another restore is staged. Not in the demo. | +| POST | `/api/backup/local/export` | Download a full backup (`.tar.gz`: database, the encryption key in use, uploads, `manifest.json`). Body `{ pin }` when an admin PIN is set (403 otherwise, 429 after too many wrong PINs). Not in the demo. | +| POST | `/api/backup/local/import` | Multipart: a `pin` field when an admin PIN is set (before the file; 403 otherwise), then one backup file (up to 4 GB): checked and staged for the next start, the current data saved for rollback → `{ staged }`. 400 for a file that isn't a backup, or one made under a different `ENCRYPTION_KEY`; 409 while another restore is staged. Not in the demo. | | GET | `/api/backup/drive/status` | Drive backup settings, and `restore: { staged, lastRestore, rollbackAvailable }`, which a backup file's restore shares. | -| POST | `/api/backup/drive/restore/cancel`, `/restore/rollback`, `/restart` | Cancel a staged restore, stage a rollback of the last one, or exit so a staged restore is applied on start-up. Shared by Drive and file restores. | +| POST | `/api/backup/drive/restore/stage`, `/backup-key` | Stage a restore from a Drive backup `{ fileId }`, or download that backup's encryption key. Both take `{ pin }` when an admin PIN is set. | +| POST | `/api/backup/drive/restore/cancel`, `/restore/rollback`, `/restart` | Cancel a staged restore, stage a rollback of the last one (`{ pin }` when an admin PIN is set), or exit so a staged restore is applied on start-up. Shared by Drive and file restores. | | PUT | `/api/connections/homeassistant` | Save URL / token / weather entity. An omitted or empty `token` keeps the stored one. Rejects non-http(s) URLs with 400. | | POST | `/api/connections/homeassistant/test` | `{ok, message, version}` — health check against `GET /api/`. | | GET | `/api/connections/homeassistant/weather-entities` | List `weather.*` entities for the Admin Panel picker. | @@ -375,9 +376,11 @@ draws (`server/ha-panels/template.html`); each is also a plugin row | Method | Path | Purpose | | --- | --- | --- | | GET | `/api/admin-pin/exists` | Whether a PIN is set. | -| POST | `/api/admin-pin/set` | Set/replace the PIN. | +| POST | `/api/admin-pin/set` | Set/replace the PIN (4 to 8 digits), stored as salted scrypt. | | DELETE | `/api/admin-pin` | Remove the PIN. | -| POST | `/api/admin-pin/verify` | Verify an entered PIN. | +| POST | `/api/admin-pin/verify` | Verify an entered PIN → `{ valid }`. After five wrong PINs, 429 with `Retry-After` for 30 seconds, doubling to five minutes, even for the right one; the limit is shared with every route that checks the PIN. | + +Any write (`POST`, `PUT`, `PATCH`, `DELETE`) whose `Origin` is another website answers 403 (`server/utils/crossSiteWrites.js`). Requests with no `Origin`, plugin frames, `CORS_ORIGINS` and the dev server are unaffected. > This table is generated from the route registrations in > [`server/index.js`](../../server/index.js). When you add or change a route, diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 10c434af..4288ba5c 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -150,8 +150,15 @@ the chore widget, stored as `choreWidgetSettings.soundEnabled` in that device's ## Security notes -- HomeGlow has **no authentication** beyond the optional Admin PIN. Do not expose it - directly to the internet. +- HomeGlow has **no authentication** and is built for a trusted home network. Do not + expose it to the internet. +- The optional Admin PIN is tamper resistance, not access control: it guards the + Admin Panel on a display, and the server checks it only for backups, restores and + Home Assistant unlock actions. Wrong PINs are rate-limited (five free tries, then + a wait that doubles up to five minutes). +- Writes from other websites are refused. A split frontend on another origin needs + that origin in `CORS_ORIGINS`. - For HTTPS / a custom domain, put it behind a reverse proxy (Nginx, Traefik, - Cloudflare Tunnel, etc.) and add your own access control. + Cloudflare Tunnel, etc.). If the proxy reaches the internet, add your own access + control in front of it. - Keep `ENCRYPTION_KEY` secret and stable. diff --git a/server/index.js b/server/index.js index 507eb4e8..6f148c78 100644 --- a/server/index.js +++ b/server/index.js @@ -190,6 +190,8 @@ const { listPluginsFromTree } = require('./utils/pluginStoreListing'); const { PLUGIN_FRAME_TOUCH_SCRIPT } = require('./utils/pluginFrameTouch'); const { pluginFrameShimScript } = require('./utils/pluginFrameShims'); const pluginRequestPolicy = require('./utils/pluginRequestPolicy'); +const { isCrossSiteWrite } = require('./utils/crossSiteWrites'); +const adminPin = require('./utils/adminPin'); const { sqliteUtcToIso, sqliteUtcToMs } = require('./utils/sqliteTime'); const { DEVICE_NAME_RULE_MESSAGE, @@ -647,6 +649,13 @@ fastify.addHook('onRequest', async (request, reply) => { return undefined; }); +// A form or multipart POST from another website reaches the API without +// asking, CORS or not (utils/crossSiteWrites.js). +fastify.addHook('onRequest', async (request, reply) => { + if (!isCrossSiteWrite(request, isAllowedApiOrigin)) return undefined; + return reply.status(403).send({ error: 'HomeGlow accepts changes only from its own pages.' }); +}); + fastify.register(multipart, { limits: { fileSize: 25 * 1024 * 1024, // 25MB per file @@ -7238,17 +7247,7 @@ fastify.post('/api/admin-pin/set', async (request, reply) => { } try { - const pinHash = crypto.createHash('sha256').update(pin).digest('hex'); - const existingPin = db.prepare('SELECT id FROM admin_pin WHERE id = 1').get(); - - if (existingPin) { - const stmt = db.prepare('UPDATE admin_pin SET pin_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1'); - stmt.run(pinHash); - } else { - const stmt = db.prepare('INSERT INTO admin_pin (id, pin_hash) VALUES (1, ?)'); - stmt.run(pinHash); - } - + await adminPin.setAdminPin(db, pin); return { success: true, message: 'PIN set successfully' }; } catch (error) { console.error('Error setting PIN:', error); @@ -7276,16 +7275,13 @@ fastify.post('/api/admin-pin/verify', async (request, reply) => { } try { - const storedPin = db.prepare('SELECT pin_hash FROM admin_pin WHERE id = 1').get(); - - if (!storedPin) { + const result = await adminPin.checkAdminPin(db, pin); + if (result.noPin) { return reply.status(404).send({ error: 'No PIN configured' }); } - - const pinHash = crypto.createHash('sha256').update(pin).digest('hex'); - const isValid = pinHash === storedPin.pin_hash; - - return { valid: isValid }; + // Wrong guesses count toward one limit (utils/adminPin.js). + if (result.reason === 'locked') return adminPin.refusePin(reply, result); + return { valid: result.ok }; } catch (error) { console.error('Error verifying PIN:', error); reply.status(500).send({ error: 'Failed to verify PIN' }); diff --git a/server/routes/driveBackup.js b/server/routes/driveBackup.js index 59e360c4..96a3a912 100644 --- a/server/routes/driveBackup.js +++ b/server/routes/driveBackup.js @@ -2,7 +2,7 @@ const path = require('path'); const googleConnection = require('../services/googleConnection'); const driveBackup = require('../services/googleDriveBackup'); const driveRestore = require('../services/driveRestore'); -const { adminPinMatches } = require('../utils/adminPin'); +const { checkAdminPin, refusePin } = require('../utils/adminPin'); module.exports = async function (fastify, opts) { const db = opts.db; @@ -84,11 +84,17 @@ module.exports = async function (fastify, opts) { // services/driveRestore.js). Staging validates the backup end-to-end and // snapshots the current data set so the restore can be rolled back. + // Staging a restore or a rollback replaces the household's data at the + // next start, so both ask for the admin PIN when one is set (the body's + // `pin`). Cancel and restart don't: one drops a staged restore, the other + // applies only one the PIN already allowed. fastify.post('/api/backup/drive/restore/stage', async (request, reply) => { - const { fileId } = request.body || {}; + const { fileId, pin } = request.body || {}; if (!fileId || typeof fileId !== 'string') { return reply.code(400).send({ error: 'A backup fileId is required.' }); } + const pinCheck = await checkAdminPin(db, pin); + if (!pinCheck.ok) return refusePin(reply, pinCheck, 'The admin PIN is needed to restore a backup.'); try { const account = googleConnection.getConnectedAccount(db); if (!account) return reply.status(404).send({ error: 'No Google account connected.' }); @@ -119,9 +125,8 @@ module.exports = async function (fastify, opts) { if (!fileId || typeof fileId !== 'string') { return reply.code(400).send({ error: 'A backup fileId is required.' }); } - if (!adminPinMatches(db, pin)) { - return reply.code(403).send({ error: 'The admin PIN is needed to download an encryption key.' }); - } + const pinCheck = await checkAdminPin(db, pin); + if (!pinCheck.ok) return refusePin(reply, pinCheck, 'The admin PIN is needed to download an encryption key.'); try { const account = googleConnection.getConnectedAccount(db); if (!account) return reply.status(404).send({ error: 'No Google account connected.' }); @@ -140,6 +145,8 @@ module.exports = async function (fastify, opts) { }); fastify.post('/api/backup/drive/restore/rollback', async (request, reply) => { + const pinCheck = await checkAdminPin(db, request.body?.pin); + if (!pinCheck.ok) return refusePin(reply, pinCheck, 'The admin PIN is needed to roll back a restore.'); try { const staged = driveRestore.stageRollback(db, dbPath, uploadsDir); return { success: true, staged }; diff --git a/server/routes/haPanels.js b/server/routes/haPanels.js index ae86f010..9fe69deb 100644 --- a/server/routes/haPanels.js +++ b/server/routes/haPanels.js @@ -37,6 +37,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const rules = require('../services/haPanels'); +const { checkAdminPin, refusePin } = require('../utils/adminPin'); const media = require('../services/haMedia'); const TEMPLATE_PATH = path.join(__dirname, '..', 'ha-panels', 'template.html'); @@ -459,8 +460,6 @@ function registerHaPanelRoutes(fastify, { db, live, ha, demoMode, installPluginR return recent.length > RATE_LIMIT; }; - const pinHash = (pin) => crypto.createHash('sha256').update(String(pin)).digest('hex'); - // A display that remembers the admin PIN is trusted with what the PIN // guards: the household chose to unlock it. const displayRemembersPin = (deviceName) => { @@ -578,9 +577,11 @@ function registerHaPanelRoutes(fastify, { db, live, ha, demoMode, installPluginR // Unlocking, disarming and opening a garage door need the household PIN // when PIN protection is on, unless this display remembers it. if (calls.some((call) => call.opens) && pinProtection() && !displayRemembersPin(device)) { - const stored = db.prepare('SELECT pin_hash FROM admin_pin WHERE id = 1').get(); - if (stored && (typeof pin !== 'string' || pinHash(pin) !== stored.pin_hash)) { - logAction({ pluginId, device, entity: targets.join(','), action, ok: false, error: pin ? 'wrong PIN' : 'PIN needed' }); + const check = await checkAdminPin(db, typeof pin === 'string' ? pin : undefined); + if (!check.ok) { + const locked = check.reason === 'locked'; + logAction({ pluginId, device, entity: targets.join(','), action, ok: false, error: locked ? 'too many wrong PINs' : pin ? 'wrong PIN' : 'PIN needed' }); + if (locked) return refusePin(reply, check); return reply.status(401).send({ error: pin ? 'That PIN is not right.' : 'Enter the household PIN.', needsPin: true }); } } diff --git a/server/routes/localBackup.js b/server/routes/localBackup.js index d6b542bd..a9a3b550 100644 --- a/server/routes/localBackup.js +++ b/server/routes/localBackup.js @@ -9,16 +9,18 @@ // routes, which are not Google-specific. // // POST /api/backup/local/export { pin? } -> the .tar.gz, as a download -// POST /api/backup/local/import multipart, one file -> { staged } +// POST /api/backup/local/import multipart, pin? then one file -> { staged } // -// An export holds the key that unlocks every stored credential, so it asks -// for the admin PIN when one is set. Neither is available in the demo. +// An export holds the key that unlocks every stored credential, and an import +// replaces the household's data, so both ask for the admin PIN when one is +// set. An import's `pin` field must come before its file. Neither is +// available in the demo. const fs = require('fs'); const path = require('path'); const { pipeline } = require('stream/promises'); const driveBackup = require('../services/googleDriveBackup'); const driveRestore = require('../services/driveRestore'); -const { adminPinMatches } = require('../utils/adminPin'); +const { checkAdminPin, refusePin } = require('../utils/adminPin'); // The largest backup file an import takes. Photos can make a backup large; // the bundled nginx allows the same (client/nginx.conf). @@ -42,9 +44,8 @@ function registerLocalBackupRoutes(fastify, { db, dbPath, uploadsDir = null, dem fastify.post('/api/backup/local/export', async (request, reply) => { if (notInDemo(reply)) return undefined; - if (!adminPinMatches(db, request.body?.pin)) { - return reply.code(403).send({ error: 'The admin PIN is needed to download a backup.' }); - } + const pinCheck = await checkAdminPin(db, request.body?.pin); + if (!pinCheck.ok) return refusePin(reply, pinCheck, 'The admin PIN is needed to download a backup.'); const workDir = fs.mkdtempSync(path.join(path.dirname(dbPath), 'export-')); const cleanUp = () => fs.rm(workDir, { recursive: true, force: true }, () => {}); try { @@ -78,6 +79,12 @@ function registerLocalBackupRoutes(fastify, { db, dbPath, uploadsDir = null, dem return reply.code(400).send({ error: 'Send the backup file as a form upload.' }); } if (!part) return reply.code(400).send({ error: 'Choose a backup file to import.' }); + // Checked before the file is written: a refused upload is drained, not kept. + const pinCheck = await checkAdminPin(db, part.fields?.pin?.value); + if (!pinCheck.ok) { + part.file.resume(); + return refusePin(reply, pinCheck, 'The admin PIN is needed to restore a backup.'); + } // Kept beside the database, on the data volume, rather than in the // container's temp folder: a backup can be large. diff --git a/server/tests/adminPin.test.js b/server/tests/adminPin.test.js new file mode 100644 index 00000000..1f834173 --- /dev/null +++ b/server/tests/adminPin.test.js @@ -0,0 +1,95 @@ +// The admin PIN's storage and its limit on wrong guesses (mrramam/HomeGlow#62). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const adminPin = require('../utils/adminPin'); + +// Just enough of the database for the admin_pin row. +function fakeDb(pinHash = null) { + const row = { pin_hash: pinHash }; + return { + row, + prepare(query) { + if (query.startsWith('SELECT pin_hash')) return { get: () => (row.pin_hash ? { pin_hash: row.pin_hash } : undefined) }; + if (query.includes('INSERT INTO admin_pin')) return { run: (hash) => { row.pin_hash = hash; } }; + throw new Error(`Unexpected query: ${query}`); + }, + }; +} + +const sha256 = (pin) => crypto.createHash('sha256').update(pin).digest('hex'); + +test.beforeEach(() => adminPin.resetPinAttempts()); + +test('a PIN is stored as salted scrypt, never as itself', async () => { + const a = await adminPin.hashPin('1234'); + const b = await adminPin.hashPin('1234'); + assert.match(a, /^scrypt\$[\w-]+\$[\w-]+$/); + assert.notEqual(a, b, 'a fresh salt each time'); + assert.ok(!a.includes('1234')); + assert.equal(await adminPin.pinMatchesHash('1234', a), true); + assert.equal(await adminPin.pinMatchesHash('1235', a), false); +}); + +test('a PIN saved by an older version (unsalted SHA-256) still matches', async () => { + assert.equal(await adminPin.pinMatchesHash('1234', sha256('1234')), true); + assert.equal(await adminPin.pinMatchesHash('4321', sha256('1234')), false); + assert.equal(await adminPin.pinMatchesHash('1234', 'scrypt$broken'), false); + assert.equal(await adminPin.pinMatchesHash(1234, sha256('1234')), false); +}); + +test('the right legacy PIN is rehashed as scrypt; a wrong one leaves it alone', async () => { + const db = fakeDb(sha256('2468')); + assert.deepEqual(await adminPin.checkAdminPin(db, '1111'), { ok: false, reason: 'wrong' }); + assert.equal(db.row.pin_hash, sha256('2468')); + assert.deepEqual(await adminPin.checkAdminPin(db, '2468'), { ok: true }); + assert.match(db.row.pin_hash, /^scrypt\$/); + assert.deepEqual(await adminPin.checkAdminPin(db, '2468'), { ok: true }); +}); + +test('no PIN set lets everything through; a missing PIN is not a guess', async () => { + assert.deepEqual(await adminPin.checkAdminPin(fakeDb(), undefined), { ok: true, noPin: true }); + const db = fakeDb(await adminPin.hashPin('1234')); + for (let i = 0; i < 10; i += 1) { + assert.deepEqual(await adminPin.checkAdminPin(db, undefined), { ok: false, reason: 'missing' }); + } + assert.deepEqual(await adminPin.checkAdminPin(db, '1234'), { ok: true }); +}); + +test('after five wrong guesses even the right PIN waits, longer each time', async () => { + const db = fakeDb(await adminPin.hashPin('1234')); + const t0 = 1_000_000; + for (let i = 0; i < adminPin.FREE_ATTEMPTS - 1; i += 1) { + assert.equal((await adminPin.checkAdminPin(db, '0000', t0)).reason, 'wrong'); + } + assert.equal((await adminPin.checkAdminPin(db, '0000', t0)).reason, 'wrong', 'the fifth still answers'); + + const locked = await adminPin.checkAdminPin(db, '1234', t0 + 1000); + assert.deepEqual(locked, { ok: false, reason: 'locked', retryAfterSeconds: 29 }); + + // The next wrong guess after the wait doubles it. + assert.equal((await adminPin.checkAdminPin(db, '0000', t0 + 30_000)).reason, 'wrong'); + assert.equal((await adminPin.checkAdminPin(db, '1234', t0 + 30_000 + 59_000)).reason, 'locked'); + + // The right PIN once the wait is over clears the count. + assert.deepEqual(await adminPin.checkAdminPin(db, '1234', t0 + 30_000 + 60_000), { ok: true }); + assert.equal((await adminPin.checkAdminPin(db, '0000', t0 + 100_000)).reason, 'wrong'); + assert.deepEqual(await adminPin.checkAdminPin(db, '1234', t0 + 100_000), { ok: true }); +}); + +test('the wait stops growing at five minutes', async () => { + const db = fakeDb(await adminPin.hashPin('1234')); + let now = 0; + const waits = []; + for (let i = 0; i < 15; i += 1) { + assert.equal((await adminPin.checkAdminPin(db, '0000', now)).reason, 'wrong'); + // Asking with no PIN reports a wait without counting as a guess. + const locked = await adminPin.checkAdminPin(db, undefined, now); + if (locked.reason === 'locked') { + waits.push(locked.retryAfterSeconds); + now += locked.retryAfterSeconds * 1000; + } + } + assert.deepEqual(waits.slice(0, 5), [30, 60, 120, 240, 300]); + assert.ok(waits.every((wait) => wait <= 300), waits.join(',')); +}); diff --git a/server/tests/adminPinRoutes.test.js b/server/tests/adminPinRoutes.test.js new file mode 100644 index 00000000..05db8dde --- /dev/null +++ b/server/tests/adminPinRoutes.test.js @@ -0,0 +1,164 @@ +// The admin PIN routes and what they guard, on a real server +// (mrramam/HomeGlow#62, #73). One server for the file: the limit on wrong +// guesses is the server's own state, so the test that trips it runs last. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const Database = require('better-sqlite3'); +const { freePort } = require('./freePort'); + +const serverDir = path.resolve(__dirname, '..'); +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const PIN = '4321'; + +let child; +let base; +let port; +let dataDir; +let dbPath; + +test.before(async () => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'homeglow-admin-pin-')); + dbPath = path.join(dataDir, 'tasks.db'); + port = await freePort(); + child = spawn(process.execPath, ['index.js'], { + cwd: serverDir, + env: { + ...process.env, PORT: String(port), DB_PATH: dbPath, TZ: 'UTC', DEMO_MODE: 'false', + ENCRYPTION_KEY: Buffer.alloc(32, 5).toString('base64'), + HOMEGLOW_DISABLE_BACKGROUND_JOBS: '1', HOMEGLOW_DISABLE_CALENDAR_SYNC: '1', HOMEGLOW_DISABLE_UPDATE_CHECK: '1', + }, + stdio: ['ignore', 'ignore', 'ignore'], + }); + base = `http://127.0.0.1:${port}`; + const start = Date.now(); + while (Date.now() - start < 30000) { + try { if ((await fetch(`${base}/api/test`)).ok) return; } catch { /* starting */ } + await delay(250); + } + throw new Error('server did not start'); +}); + +test.after(async () => { + await new Promise((resolve) => { + if (child.exitCode !== null) return resolve(); + child.once('close', resolve); + child.kill(); + setTimeout(resolve, 5000); + }); + fs.rmSync(dataDir, { recursive: true, force: true }); +}); + +const json = async (res) => { const text = await res.text(); try { return JSON.parse(text); } catch { return text; } }; +async function call(method, pathname, body) { + const res = await fetch(`${base}${pathname}`, { + method, + headers: body === undefined ? {} : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { status: res.status, headers: res.headers, body: await json(res) }; +} +async function importBackup(pin) { + const form = new FormData(); + if (pin !== undefined) form.append('pin', pin); + form.append('file', new Blob([Buffer.from('not a backup')], { type: 'application/gzip' }), 'backup.tar.gz'); + const res = await fetch(`${base}/api/backup/local/import`, { method: 'POST', body: form }); + return { status: res.status, body: await json(res) }; +} +// node:http, so the request carries exactly the headers a browser would. +function browserPost(pathname, headers, body = '{}') { + return new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path: pathname, method: 'POST', headers: { 'Content-Type': 'application/json', ...headers } }, (res) => { + let text = ''; + res.on('data', (chunk) => { text += chunk; }); + res.on('end', () => resolve({ status: res.statusCode, body: text })); + }); + req.on('error', reject); + req.end(body); + }); +} +const storedHash = () => { + const db = new Database(dbPath, { readonly: true }); + try { return db.prepare('SELECT pin_hash FROM admin_pin WHERE id = 1').get()?.pin_hash; } finally { db.close(); } +}; + +test('set stores a salted hash, and exists never shows it', async () => { + assert.deepEqual((await call('GET', '/api/admin-pin/exists')).body, { exists: false }); + assert.equal((await call('POST', '/api/admin-pin/set', { pin: '12' })).status, 400); + assert.equal((await call('POST', '/api/admin-pin/set', { pin: 'abcd' })).status, 400); + assert.equal((await call('POST', '/api/admin-pin/set', { pin: PIN })).status, 200); + + const exists = await call('GET', '/api/admin-pin/exists'); + assert.deepEqual(exists.body, { exists: true }); + assert.match(storedHash(), /^scrypt\$/); + assert.ok(!JSON.stringify(exists.body).includes(storedHash().split('$')[2])); +}); + +test('verify accepts the PIN and refuses anything else', async () => { + assert.deepEqual((await call('POST', '/api/admin-pin/verify', { pin: PIN })).body, { valid: true }); + assert.deepEqual((await call('POST', '/api/admin-pin/verify', { pin: '1111' })).body, { valid: false }); + assert.equal((await call('POST', '/api/admin-pin/verify', {})).status, 400); + // Reset the count: the right PIN clears the wrong guess above. + assert.deepEqual((await call('POST', '/api/admin-pin/verify', { pin: PIN })).body, { valid: true }); +}); + +test('a PIN saved by an older version works and is rehashed', async () => { + const db = new Database(dbPath); + db.prepare('UPDATE admin_pin SET pin_hash = ? WHERE id = 1').run(crypto.createHash('sha256').update('8642').digest('hex')); + db.close(); + assert.deepEqual((await call('POST', '/api/admin-pin/verify', { pin: '8642' })).body, { valid: true }); + assert.match(storedHash(), /^scrypt\$/); + assert.equal((await call('POST', '/api/admin-pin/set', { pin: PIN })).status, 200); +}); + +test('staging a restore or a rollback asks for the PIN', async () => { + const noPin = await importBackup(); + assert.equal(noPin.status, 403); + assert.equal(noPin.body.needsPin, true); + // With the PIN the file is read, and this one isn't a backup. + const withPin = await importBackup(PIN); + assert.equal(withPin.status, 400, JSON.stringify(withPin.body)); + + assert.equal((await call('POST', '/api/backup/drive/restore/rollback', {})).status, 403); + assert.notEqual((await call('POST', '/api/backup/drive/restore/rollback', { pin: PIN })).status, 403); + + assert.equal((await call('POST', '/api/backup/drive/restore/stage', { fileId: 'x' })).status, 403); + // Past the PIN, there is no Google account. + assert.equal((await call('POST', '/api/backup/drive/restore/stage', { fileId: 'x', pin: PIN })).status, 404); + + // Cancel and restart apply nothing the PIN hasn't allowed. + assert.equal((await call('POST', '/api/backup/drive/restore/cancel', {})).status, 200); + assert.equal((await call('POST', '/api/backup/drive/restart', {})).status, 400, 'no restore is staged'); +}); + +test('a write from another website is refused; the dashboard and scripts are not', async () => { + const body = JSON.stringify({ key: 'CALENDAR_DEDUP_ENABLED', value: 'true' }); + const crossSite = await browserPost('/api/settings', { Origin: 'https://evil.example', 'Sec-Fetch-Site': 'cross-site' }, body); + assert.equal(crossSite.status, 403); + const sameOrigin = await browserPost('/api/settings', { Origin: `http://homeglow.local:${port}`, 'Sec-Fetch-Site': 'same-origin' }, body); + assert.equal(sameOrigin.status, 200); + const script = await browserPost('/api/settings', {}, body); + assert.equal(script.status, 200); +}); + +test('five wrong guesses make even the right PIN wait, on every route that checks it', async () => { + for (let i = 0; i < 5; i += 1) { + assert.deepEqual((await call('POST', '/api/admin-pin/verify', { pin: '0000' })).body, { valid: false }); + } + const locked = await call('POST', '/api/admin-pin/verify', { pin: PIN }); + assert.equal(locked.status, 429); + assert.ok(Number(locked.headers.get('retry-after')) > 0); + assert.ok(locked.body.retryAfterSeconds > 0); + + const exportLocked = await call('POST', '/api/backup/local/export', { pin: PIN }); + assert.equal(exportLocked.status, 429); + + // Clearing the PIN needs no PIN, which is the Admin Panel's job. + assert.equal((await call('DELETE', '/api/admin-pin')).status, 200); + assert.deepEqual((await call('GET', '/api/admin-pin/exists')).body, { exists: false }); +}); diff --git a/server/tests/crossSiteWrites.test.js b/server/tests/crossSiteWrites.test.js new file mode 100644 index 00000000..7f8fea84 --- /dev/null +++ b/server/tests/crossSiteWrites.test.js @@ -0,0 +1,35 @@ +// Writes sent by another website are refused (mrramam/HomeGlow#62). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { isCrossSiteWrite } = require('../utils/crossSiteWrites'); + +const allowed = (origin) => origin === 'http://localhost:3012'; +const write = (headers, method = 'POST') => isCrossSiteWrite({ method, headers }, allowed); + +test('a write from another site is refused', () => { + assert.equal(write({ origin: 'https://evil.example', 'sec-fetch-site': 'cross-site', host: 'homeglow.local' }), true); + // Same site, another origin: a different service on the same box. + assert.equal(write({ origin: 'http://homeglow.local:8080', 'sec-fetch-site': 'same-site', host: 'homeglow.local' }), true); + assert.equal(write({ origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' }, 'DELETE'), true); +}); + +test("the dashboard's own writes go through, behind any proxy", () => { + // The browser says same-origin, whatever Host the proxy passed on. + assert.equal(write({ origin: 'https://home.example.com', 'sec-fetch-site': 'same-origin', host: 'homeglow-backend:5000' }), false); +}); + +test('an older browser with no Sec-Fetch-Site is judged by host', () => { + // nginx passes Host $host, without the port. + assert.equal(write({ origin: 'http://192.168.1.20:3000', host: '192.168.1.20' }), false); + assert.equal(write({ origin: 'https://home.example.com', host: 'backend', 'x-forwarded-host': 'home.example.com' }), false); + assert.equal(write({ origin: 'https://evil.example', host: '192.168.1.20' }), true); + assert.equal(write({ origin: 'not a url', host: '192.168.1.20' }), true); +}); + +test('reads, plugins, scripts and allowed origins are untouched', () => { + assert.equal(write({ origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' }, 'GET'), false); + assert.equal(write({ origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' }, 'OPTIONS'), false); + assert.equal(write({ origin: 'null', 'sec-fetch-site': 'cross-site' }), false, 'pluginRequestPolicy decides'); + assert.equal(write({}), false, 'curl, Home Assistant, other servers'); + assert.equal(write({ origin: 'http://localhost:3012', 'sec-fetch-site': 'same-site' }), false, 'the Vite dev server'); +}); diff --git a/server/utils/adminPin.js b/server/utils/adminPin.js index 307f78cd..4b2d6f0a 100644 --- a/server/utils/adminPin.js +++ b/server/utils/adminPin.js @@ -1,17 +1,116 @@ -// The admin PIN, checked by the server itself. +// The admin PIN, checked by the server itself (mrramam/HomeGlow#62). // -// The Admin Panel's PIN guards the panel in the browser, not the API. The few -// routes that hand out a secret (a backup's encryption key, a whole backup -// with its key) check it here as well: true when no PIN is set, or when -// `pin` is the PIN. Compared in constant time. +// The PIN keeps casual hands out of the Admin Panel on a shared display. It +// isn't a login: most of the API takes requests from any device on the home +// network. The routes that hand out a secret or replace the household's data +// (a backup with its encryption key, staging a restore or a rollback) and a +// Home Assistant panel's unlock actions check it here as well. +// +// Stored as scrypt with a random salt. A PIN saved by an older version, an +// unsalted SHA-256, still works and is rehashed on its next correct entry. +// Every check shares one limit on wrong guesses: a 4-digit PIN would +// otherwise fall to 10,000 requests. const crypto = require('crypto'); -function adminPinMatches(db, pin) { +const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }; +const SCRYPT_KEYLEN = 32; +const LEGACY_SHA256 = /^[0-9a-f]{64}$/; + +// Wrong guesses allowed before a wait, then the wait for each one after, +// doubling up to the cap. A household member who mistypes a few times never +// waits; 10,000 guesses at five minutes each take weeks. +const FREE_ATTEMPTS = 5; +const FIRST_LOCK_MS = 30 * 1000; +const MAX_LOCK_MS = 5 * 60 * 1000; + +const scrypt = (pin, salt) => new Promise((resolve, reject) => { + crypto.scrypt(pin, salt, SCRYPT_KEYLEN, SCRYPT_PARAMS, (error, key) => (error ? reject(error) : resolve(key))); +}); + +async function hashPin(pin) { + const salt = crypto.randomBytes(16); + const key = await scrypt(String(pin), salt); + return `scrypt$${salt.toString('base64url')}$${key.toString('base64url')}`; +} + +const sameBytes = (a, b) => a.length === b.length && crypto.timingSafeEqual(a, b); + +// Whether `pin` is the one `stored` was made from, in either format. +async function pinMatchesHash(pin, stored) { + if (typeof pin !== 'string' || typeof stored !== 'string') return false; + if (LEGACY_SHA256.test(stored)) { + return sameBytes(Buffer.from(crypto.createHash('sha256').update(pin).digest('hex')), Buffer.from(stored)); + } + const [scheme, salt, key] = stored.split('$'); + if (scheme !== 'scrypt' || !salt || !key) return false; + return sameBytes(await scrypt(pin, Buffer.from(salt, 'base64url')), Buffer.from(key, 'base64url')); +} + +async function setAdminPin(db, pin) { + const pinHash = await hashPin(pin); + db.prepare(` + INSERT INTO admin_pin (id, pin_hash) VALUES (1, ?) + ON CONFLICT(id) DO UPDATE SET pin_hash = excluded.pin_hash, updated_at = CURRENT_TIMESTAMP + `).run(pinHash); +} + +// One limit for the whole server, not per device: behind the bundled nginx +// every display has the same address. +const attempts = { failures: 0, lockedUntil: 0 }; + +function resetPinAttempts() { + attempts.failures = 0; + attempts.lockedUntil = 0; +} + +/** + * Checks `pin` against the stored PIN. + * -> { ok: true, noPin: true } no PIN is set + * | { ok: true } the right PIN + * | { ok: false, reason: 'missing' } none given; not counted as a guess + * | { ok: false, reason: 'wrong' } + * | { ok: false, reason: 'locked', retryAfterSeconds } + * While locked, even the right PIN is refused, or the limit would only slow + * a guesser down between hits. + */ +async function checkAdminPin(db, pin, now = Date.now()) { const stored = db.prepare('SELECT pin_hash FROM admin_pin WHERE id = 1').get(); - if (!stored) return true; - const given = typeof pin === 'string' ? crypto.createHash('sha256').update(pin).digest('hex') : ''; - return given.length === stored.pin_hash.length - && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(stored.pin_hash)); + if (!stored) return { ok: true, noPin: true }; + if (attempts.lockedUntil > now) { + return { ok: false, reason: 'locked', retryAfterSeconds: Math.ceil((attempts.lockedUntil - now) / 1000) }; + } + if (typeof pin !== 'string' || pin === '') return { ok: false, reason: 'missing' }; + + if (await pinMatchesHash(pin, stored.pin_hash)) { + resetPinAttempts(); + if (LEGACY_SHA256.test(stored.pin_hash)) await setAdminPin(db, pin); + return { ok: true }; + } + attempts.failures += 1; + if (attempts.failures >= FREE_ATTEMPTS) { + attempts.lockedUntil = now + Math.min(MAX_LOCK_MS, FIRST_LOCK_MS * 2 ** (attempts.failures - FREE_ATTEMPTS)); + } + return { ok: false, reason: 'wrong' }; +} + +// Answers a refused check: 429 while locked, otherwise 403 with `message`. +function refusePin(reply, result, message) { + if (result.reason === 'locked') { + return reply.code(429).header('Retry-After', String(result.retryAfterSeconds)).send({ + error: `Too many wrong PINs. Try again in ${result.retryAfterSeconds} seconds.`, + retryAfterSeconds: result.retryAfterSeconds, + needsPin: true, + }); + } + return reply.code(403).send({ error: message, needsPin: true }); } -module.exports = { adminPinMatches }; +module.exports = { + FREE_ATTEMPTS, + hashPin, + pinMatchesHash, + setAdminPin, + checkAdminPin, + refusePin, + resetPinAttempts, +}; diff --git a/server/utils/crossSiteWrites.js b/server/utils/crossSiteWrites.js new file mode 100644 index 00000000..cede94f0 --- /dev/null +++ b/server/utils/crossSiteWrites.js @@ -0,0 +1,47 @@ +// Writes sent by another website (mrramam/HomeGlow#62). +// +// CORS keeps other sites from reading the API, but a browser still sends a +// form or multipart POST from any page without asking. HomeGlow has no login, +// so a page open on any device in the house could post to it. This refuses a +// write whose Origin is another site's. +// +// A write goes through when: +// - it has no Origin: curl, scripts, Home Assistant, other servers; +// - its Origin is "null": a plugin frame, which pluginRequestPolicy.js +// already holds to its token and routes; +// - its Origin may use the API anyway: CORS_ORIGINS, or the Vite dev server; +// - the browser says it's same-origin (Sec-Fetch-Site), which no proxy in +// between can get wrong; +// - from an older browser that sends no Sec-Fetch-Site, its Origin's host +// is the host the request was sent to. The bundled nginx passes +// `Host $host`, which drops the port, so only hostnames are compared. +const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +const hostnameOf = (value) => { + if (typeof value !== 'string' || !value) return null; + try { + return new URL(`http://${value.split(',')[0].trim()}`).hostname.toLowerCase(); + } catch { + return null; + } +}; + +function isCrossSiteWrite({ method, headers }, isAllowedOrigin) { + if (READ_METHODS.has(String(method).toUpperCase())) return false; + const origin = headers.origin; + if (!origin || origin === 'null') return false; + if (isAllowedOrigin(origin)) return false; + + const fetchSite = headers['sec-fetch-site']; + if (fetchSite) return fetchSite !== 'same-origin'; + + let originHost; + try { + originHost = new URL(origin).hostname.toLowerCase(); + } catch { + return true; + } + return ![headers.host, headers['x-forwarded-host']].some((host) => hostnameOf(host) === originHost); +} + +module.exports = { isCrossSiteWrite };