fix: hlavičky s identitou přijímat jen z důvěryhodných adres
CI / Generate TypeScript types (push) Successful in 11s
CI / Server unit tests (push) Successful in 31s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 47s
CI / Playwright E2E tests (push) Successful in 1m39s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s

This commit is contained in:
2026-09-24 16:12:46 +02:00
parent 89d2cc2bc3
commit 7cda7cfdb6
5 changed files with 168 additions and 36 deletions
+26 -26
View File
@@ -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`) - [ ] `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 - [ ] `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í - [ ] `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] 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í) - [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 - 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.) - 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 - [X] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
- [ ] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu - [X] 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 - [X] 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 - [X] 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í) - [X] 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ášť) - [X] 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) - [X] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
- [ ] Umožnit zadat URL/tokeny uživatelem - [X] Umožnit zadat URL/tokeny uživatelem — ntfy, Discord, MS Teams (Gotify jen globálně na serveru)
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat - [X] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
- [ ] Zavést notifikace typu "Jdeme na oběd" - [X] Zavést notifikace typu "Jdeme na oběd"
- [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu - [X] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
- [ ] Vylepšit parsery restaurací - [X] Vylepšit parsery restaurací
- [ ] Sladovnická - [X] Sladovnická
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru - [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í
- [ ] U Motlíků - [X] U Motlíků — odpadá, podnik už v aplikaci není
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.') - [X] 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í) - [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í)
- [ ] TechTower - [X] 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)') - [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
- [ ] 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] 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 - 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 - 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 ( , ) - [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) - [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
- [ ] Zavést složku /data - [X] Zavést složku /data
- [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu - [ ] 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 - [ ] 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 - [ ] 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() - [ ] 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í - [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
+4 -1
View File
@@ -33,7 +33,10 @@
# V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS. # V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS.
# HTTP_REMOTE_USER_ENABLED=true # 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 # 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'. # Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'.
+9 -8
View File
@@ -30,6 +30,7 @@ import groupRoutes from "./routes/groupRoutes";
import storeRoutes from "./routes/storeRoutes"; import storeRoutes from "./routes/storeRoutes";
import butterflyRoutes from "./routes/butterflyRoutes"; import butterflyRoutes from "./routes/butterflyRoutes";
import { getConfettiCharacters, getConfettiImage } from "./confetti"; import { getConfettiCharacters, getConfettiImage } from "./confetti";
import { getTrustedRemoteUser, parseTrustedIps } from "./trustedHeaders";
import { getGroupLimits } from "./limits"; import { getGroupLimits } from "./limits";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; 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_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user'; const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
let trustedProxies = parseTrustedIps('');
if (HTTP_REMOTE_USER_ENABLED) { if (HTTP_REMOTE_USER_ENABLED) {
if (!process.env.HTTP_REMOTE_TRUSTED_IPS) { 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.'); 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()); const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS); 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.'); console.log('Zapnutý login přes hlavičky z proxy.');
} }
@@ -157,14 +161,14 @@ app.get("/api/whoami", (req, res) => {
delete req.headers["cookie"] delete req.headers["cookie"]
console.log(req.headers) 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) => { app.post("/api/login", (req, res) => {
if (HTTP_REMOTE_USER_ENABLED) { 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) { if (remoteUser && remoteUser.length > 0) {
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true)); res.status(200).json(generateToken(remoteUser, true));
} else { } else {
// Přihlašuje proxy (trusted headers) — bez hlavičky nejde o chybu serveru, ale o nepřihlášeného uživatele // 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.' }); 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,18 +239,15 @@ app.get("/api/confetti/image/:id", async (req, res, next) => {
app.use("/api/", (req, res, next) => { app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) { 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') { if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
delete req.headers["cookie"] delete req.headers["cookie"]
console.log(req.headers) console.log(req.headers)
} }
if (remoteUser && remoteUser.length > 0) { if (remoteName && ENVIRONMENT !== "production") {
const remoteName = Buffer.from(remoteUser, 'latin1').toString();
if (ENVIRONMENT !== "production") {
console.log("Tvuj username: %s.", remoteName); console.log("Tvuj username: %s.", remoteName);
} }
} }
}
if (!req.headers.authorization) { if (!req.headers.authorization) {
return res.status(401).json({ error: 'Nebyl předán autentizační token' }); return res.status(401).json({ error: 'Nebyl předán autentizační token' });
} }
+64
View File
@@ -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();
});
});
+64
View File
@@ -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<string>();
/**
* 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();
}