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
+4 -1
View File
@@ -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'.
+10 -9
View File
@@ -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) {
+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();
}