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
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:
@@ -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í
|
||||
|
||||
@@ -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'.
|
||||
|
||||
+9
-8
@@ -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,18 +239,15 @@ 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") {
|
||||
if (remoteName && ENVIRONMENT !== "production") {
|
||||
console.log("Tvuj username: %s.", remoteName);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!req.headers.authorization) {
|
||||
return res.status(401).json({ error: 'Nebyl předán autentizační token' });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user