diff --git a/TODO.md b/TODO.md index 93bcc46..0bac9e0 100644 --- a/TODO.md +++ b/TODO.md @@ -4,41 +4,41 @@ - [ ] `foodRoutes.ts` per-pod rate limiter (`rateLimits` map) — s více replikami může uživatel překročit limit ~N× rychleji; přesunout do Redis (např. `INCR` + `EXPIRE`) - [ ] `easterEggRoutes.ts` — náhodně generované URL easter eggů jsou per-pod; URL funguje pouze na podu, který ji vygeneroval; zvážit deterministické seedy nebo sdílení přes Redis - [ ] `service.ts` — komplexní víceúrovňové funkce (`addChoice`, `removeChoiceIfPresent`) provádějí více po sobě jdoucích zápisů do stejného Redis klíče; pro plnou atomicitu je potřeba per-klíčový distribuovaný zámek (Redlock nebo `SET NX EX`) nebo sloučení logiky do jednoho `updateData` volání -- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli +- [X] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli — kontroluje se skutečná adresa spojení (trustedHeaders.ts) - [X] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď) - [X] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování) -- [ ] Možnost úhrady celé útraty jednou osobou +- [X] Možnost úhrady celé útraty jednou osobou - Základní myšlenka: jedna osoba uhradí celou útratu (v zájmu rychlosti odbavení), ostatním se automaticky vygeneruje QR kód, kterým následně uhradí svoji část útraty - Obecně to bude problém např. pokud si někdo objedná něco navíc (pití apod.) - - [ ] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul - - [ ] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu - - [ ] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům - - [ ] Generování a zobrazení QR kódů ostatním strávníkům - - [ ] Umožnit u každého strávníka připočíst vlastní částku (např. za pití) - - [ ] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť) -- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams) - - [ ] Umožnit zadat URL/tokeny uživatelem - - [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat - - [ ] Zavést notifikace typu "Jdeme na oběd" - - [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu -- [ ] Vylepšit parsery restaurací - - [ ] Sladovnická - - [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru - - [ ] U Motlíků - - [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.') - - [ ] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění) - - [ ] TechTower - - [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)') - - [ ] Jídelní lístek se stahuje v rámci prvního požadavku daný den, ale často se jídelní lístek na stránkách aktualizuje až v průběhu pondělního dopoledne a ten zobrazený je proto neaktuální + - [X] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul + - [X] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu + - [X] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům + - [X] Generování a zobrazení QR kódů ostatním strávníkům + - [X] Umožnit u každého strávníka připočíst vlastní částku (např. za pití) + - [X] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť) +- [X] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams) + - [X] Umožnit zadat URL/tokeny uživatelem — ntfy, Discord, MS Teams (Gotify jen globálně na serveru) + - [X] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat + - [X] Zavést notifikace typu "Jdeme na oběd" + - [X] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu +- [X] Vylepšit parsery restaurací + - [X] Sladovnická + - [X] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru — parser mapuje dny přímo, TODO v parseru už není + - [X] U Motlíků — odpadá, podnik už v aplikaci není + - [X] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.') + - [X] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění) + - [X] TechTower + - [X] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)') — finalizeTechTower, StaleWeekError + - [X] Jídelní lístek se stahuje v rámci prvního požadavku daný den, ale často se jídelní lístek na stránkách aktualizuje až v průběhu pondělního dopoledne a ten zobrazený je proto neaktuální — zastaralé menu se po TTL stahuje znovu - Stránka neposílá hlavičku o času poslední modifikace, takže o to se nelze opřít - Nevím aktuálně jak řešit jinak, než častějším scrapováním celé stránky - [X] Někdy jsou v názvech jídel přebytečné mezery kolem čárek ( , ) -- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru) - - [ ] Zavést složku /data +- [X] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru) — data jsou v Redis s volume /data + - [X] Zavést složku /data - [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu -- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh) +- [X] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh) — run_dev.ps1 - [ ] Implementovat Pizza day - - [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day + - [X] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day - [ ] Pizzy se samy budou při naklikání přidávat do košíku - [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch() - [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní diff --git a/server/.env.template b/server/.env.template index d41bba5..9786e6a 100644 --- a/server/.env.template +++ b/server/.env.template @@ -33,7 +33,10 @@ # V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS. # HTTP_REMOTE_USER_ENABLED=true -# Seznam IP adres nebo rozsahů oddělených čárkou, ze kterých budou akceptovány důvěryhodné hlavičky. +# Seznam IP adres nebo rozsahů (CIDR, IPv4 i IPv6) oddělených čárkou, ze kterých budou akceptovány důvěryhodné hlavičky. +# Rozhoduje skutečná adresa spojení (tj. proxy před aplikací, ne klient), hlavička od jiné adresy se ignoruje. +# Uveďte co nejužší rozsah — jen adresu/síť proxy (v Kubernetes síť podů, např. 10.42.0.0/16 u RKE2), ne celé privátní rozsahy. +# Neplatná položka = server nenaběhne. # HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24 # Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'. diff --git a/server/src/index.ts b/server/src/index.ts index a91b198..728a012 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -30,6 +30,7 @@ import groupRoutes from "./routes/groupRoutes"; import storeRoutes from "./routes/storeRoutes"; import butterflyRoutes from "./routes/butterflyRoutes"; import { getConfettiCharacters, getConfettiImage } from "./confetti"; +import { getTrustedRemoteUser, parseTrustedIps } from "./trustedHeaders"; import { getGroupLimits } from "./limits"; const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; @@ -55,12 +56,15 @@ app.use(cors({ origin: '*' })); const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false; const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user'; +let trustedProxies = parseTrustedIps(''); if (HTTP_REMOTE_USER_ENABLED) { if (!process.env.HTTP_REMOTE_TRUSTED_IPS) { throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.'); } const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim()); app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS); + // Hlavička s identitou se přijímá jen od těchto adres (kontrola skutečné adresy spojení) + trustedProxies = parseTrustedIps(process.env.HTTP_REMOTE_TRUSTED_IPS); console.log('Zapnutý login přes hlavičky z proxy.'); } @@ -157,14 +161,14 @@ app.get("/api/whoami", (req, res) => { delete req.headers["cookie"] console.log(req.headers) } - res.send(req.header(HTTP_REMOTE_USER_HEADER_NAME)); + res.send(getTrustedRemoteUser(req, HTTP_REMOTE_USER_HEADER_NAME, trustedProxies) ?? ''); }) app.post("/api/login", (req, res) => { if (HTTP_REMOTE_USER_ENABLED) { - const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME); + const remoteUser = getTrustedRemoteUser(req, HTTP_REMOTE_USER_HEADER_NAME, trustedProxies); if (remoteUser && remoteUser.length > 0) { - res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true)); + res.status(200).json(generateToken(remoteUser, true)); } else { // Přihlašuje proxy (trusted headers) — bez hlavičky nejde o chybu serveru, ale o nepřihlášeného uživatele res.status(401).json({ error: 'Přihlášení probíhá přes firemní přihlášení (proxy), ale nepřišla identita uživatele. Zkuste stránku obnovit nebo se přihlásit znovu.' }); @@ -235,16 +239,13 @@ app.get("/api/confetti/image/:id", async (req, res, next) => { app.use("/api/", (req, res, next) => { if (HTTP_REMOTE_USER_ENABLED) { - const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME); + const remoteName = getTrustedRemoteUser(req, HTTP_REMOTE_USER_HEADER_NAME, trustedProxies); if (process.env.ENABLE_HEADERS_LOGGING === 'yes') { delete req.headers["cookie"] console.log(req.headers) } - if (remoteUser && remoteUser.length > 0) { - const remoteName = Buffer.from(remoteUser, 'latin1').toString(); - if (ENVIRONMENT !== "production") { - console.log("Tvuj username: %s.", remoteName); - } + if (remoteName && ENVIRONMENT !== "production") { + console.log("Tvuj username: %s.", remoteName); } } if (!req.headers.authorization) { diff --git a/server/src/tests/trustedHeaders.test.ts b/server/src/tests/trustedHeaders.test.ts new file mode 100644 index 0000000..df657ea --- /dev/null +++ b/server/src/tests/trustedHeaders.test.ts @@ -0,0 +1,64 @@ +import express from 'express'; +import request from 'supertest'; +import { getTrustedRemoteUser, isTrustedPeer, parseTrustedIps } from '../trustedHeaders'; + +describe('parseTrustedIps / isTrustedPeer', () => { + const trusted = parseTrustedIps('10.42.0.0/16, 127.0.0.1, ::1, fd00::/8'); + + test('rozsah CIDR i jednotlivé adresy (IPv4 i IPv6)', () => { + expect(isTrustedPeer('10.42.0.84', trusted)).toBe(true); + expect(isTrustedPeer('10.43.0.1', trusted)).toBe(false); + expect(isTrustedPeer('127.0.0.1', trusted)).toBe(true); + expect(isTrustedPeer('::1', trusted)).toBe(true); + expect(isTrustedPeer('fd12:3456::1', trusted)).toBe(true); + expect(isTrustedPeer('192.168.1.10', trusted)).toBe(false); + }); + + test('IPv4 mapovaná do IPv6 (jak ji hlásí Node) se porovnává jako IPv4', () => { + expect(isTrustedPeer('::ffff:10.42.0.84', trusted)).toBe(true); + expect(isTrustedPeer('::FFFF:8.8.8.8', trusted)).toBe(false); + }); + + test('chybějící nebo nesmyslná adresa není důvěryhodná', () => { + expect(isTrustedPeer(undefined, trusted)).toBe(false); + expect(isTrustedPeer('nesmysl', trusted)).toBe(false); + }); + + test('neplatná konfigurace vyhodí chybu (server nenaběhne)', () => { + expect(() => parseTrustedIps('10.0.0.0/33')).toThrow('neplatný rozsah'); + expect(() => parseTrustedIps('proxy.local')).toThrow('neplatná adresa'); + expect(() => parseTrustedIps('10.0.0.0/x')).toThrow('neplatný rozsah'); + }); + + test('prázdný seznam nedůvěřuje nikomu', () => { + expect(isTrustedPeer('127.0.0.1', parseTrustedIps(''))).toBe(false); + }); +}); + +describe('getTrustedRemoteUser', () => { + /** Mini aplikace, která vrátí identitu z hlavičky (jako /api/whoami). */ + function buildApp(trustedList: string) { + const trusted = parseTrustedIps(trustedList); + const app = express(); + app.get('/whoami', (req, res) => res.send(getTrustedRemoteUser(req, 'remote-user', trusted) ?? '')); + return app; + } + + test('hlavička od důvěryhodné adresy se přijme (i s diakritikou v latin1)', async () => { + const res = await request(buildApp('127.0.0.1,::1,::ffff:127.0.0.1')) + .get('/whoami') + .set('remote-user', Buffer.from('Novák', 'utf8').toString('latin1')); + expect(res.text).toBe('Novák'); + }); + + test('hlavička od nedůvěryhodné adresy se ignoruje — ani X-Forwarded-For nepomůže', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => { }); + const res = await request(buildApp('10.42.0.0/16')) + .get('/whoami') + .set('remote-user', 'utocnik') + .set('X-Forwarded-For', '10.42.0.84'); + expect(res.text).toBe(''); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('nedůvěryhodné adresy')); + warn.mockRestore(); + }); +}); diff --git a/server/src/trustedHeaders.ts b/server/src/trustedHeaders.ts new file mode 100644 index 0000000..2fb79ad --- /dev/null +++ b/server/src/trustedHeaders.ts @@ -0,0 +1,64 @@ +import net from "net"; +import type { Request } from "express"; + +/** + * Seznam adres, ze kterých smí přijít hlavička s identitou uživatele (přihlášení přes proxy). + * Podporuje jednotlivé adresy i rozsahy CIDR, IPv4 i IPv6 (např. "10.42.0.0/16,127.0.0.1,::1"). + * Neplatná položka je chyba konfigurace — server kvůli ní nenaběhne (lepší než tiše důvěřovat). + */ +export function parseTrustedIps(list: string): net.BlockList { + const blockList = new net.BlockList(); + for (const raw of list.split(',').map(s => s.trim()).filter(Boolean)) { + const [address, prefix] = raw.split('/'); + const family = net.isIP(address); + if (!family) { + throw new Error(`HTTP_REMOTE_TRUSTED_IPS: neplatná adresa "${raw}"`); + } + const type = family === 4 ? 'ipv4' : 'ipv6'; + if (prefix === undefined) { + blockList.addAddress(address, type); + continue; + } + const bits = Number(prefix); + if (!Number.isInteger(bits) || bits < 0 || bits > (family === 4 ? 32 : 128)) { + throw new Error(`HTTP_REMOTE_TRUSTED_IPS: neplatný rozsah "${raw}"`); + } + blockList.addSubnet(address, bits, type); + } + return blockList; +} + +/** + * Je adresa přímého protějšku spojení mezi důvěryhodnými? IPv4 mapovaná do IPv6 + * („::ffff:10.42.0.84", jak ji hlásí Node na dual-stack socketu) se porovnává jako IPv4. + */ +export function isTrustedPeer(address: string | undefined, trusted: net.BlockList): boolean { + if (!address) return false; + const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); + const normalized = mapped ? mapped[1] : address; + const family = net.isIP(normalized); + if (!family) return false; + return trusted.check(normalized, family === 4 ? 'ipv4' : 'ipv6'); +} + +const warnedPeers = new Set(); + +/** + * Vrátí identitu uživatele z hlavičky proxy, ale jen pokud spojení přišlo z důvěryhodné adresy. + * Rozhoduje skutečná adresa TCP spojení (ne req.ip, které lze ovlivnit hlavičkou X-Forwarded-For). + * Hlavičku od nedůvěryhodné adresy ignoruje a jednou za běh ji zaloguje (odhalí chybnou konfiguraci). + */ +export function getTrustedRemoteUser(req: Request, headerName: string, trusted: net.BlockList): string | undefined { + const value = req.header(headerName); + if (!value) return undefined; + const peer = req.socket?.remoteAddress; + if (!isTrustedPeer(peer, trusted)) { + const key = peer ?? 'neznámá adresa'; + if (!warnedPeers.has(key)) { + warnedPeers.add(key); + console.warn(`Přihlášení přes hlavičky: ignoruji hlavičku ${headerName} z nedůvěryhodné adresy ${key} (viz HTTP_REMOTE_TRUSTED_IPS)`); + } + return undefined; + } + return Buffer.from(value, 'latin1').toString(); +}