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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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