diff --git a/.env.example b/.env.example index cf3d37d..3733820 100644 --- a/.env.example +++ b/.env.example @@ -30,8 +30,8 @@ MEMBER_INITIAL_PASSWORD=ChangeMe-Membre-2026! # Adds the Secure flag on the session cookie (cookie sent over HTTPS only) COOKIE_SECURE=false -# Lets the app trust X-Forwarded-* headers from the reverse proxy -TRUST_PROXY=false +# Trusts the single reverse-proxy hop so the app records the real client IP +TRUST_PROXY=true # Redirects any plain-HTTP request (through the proxy) to HTTPS FORCE_HTTPS=false diff --git a/README.md b/README.md index 964e207..51b6001 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ proxy_pass http://sbc-app:8321; ``` Ce réseau doit exister avant le déploiement (`docker network create nginx_default` -s'il manque). Derrière nginx en HTTPS, passez `TRUST_PROXY=true` et -`COOKIE_SECURE=true`, et transmettez les en-têtes `Host`/`X-Forwarded-*` + s'il manque). Derrière nginx en HTTPS, conservez `TRUST_PROXY=true`, passez + `COOKIE_SECURE=true`, et transmettez les en-têtes `Host`/`X-Forwarded-*` (`proxy_set_header Host $http_host;`). La base de données, elle, reste hors du réseau du proxy. @@ -199,8 +199,8 @@ l'utilisateur est bloqué sur un écran de changement obligatoire avant d'accéd ### Pour la production -- Placez l'application derrière HTTPS (reverse-proxy TLS) et passez `COOKIE_SECURE=true` - et `TRUST_PROXY=true`. + - Placez l'application derrière HTTPS (reverse-proxy TLS), passez `COOKIE_SECURE=true` + et conservez `TRUST_PROXY=true`. - Surchargez les valeurs par défaut (`POSTGRES_PASSWORD`, `APP_DB_PASSWORD`, `JWT_SECRET`, mots de passe initiaux) et changez le mot de passe admin après la première connexion. diff --git a/docker-compose.yml b/docker-compose.yml index e2b0812..7747325 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,7 +54,7 @@ services: # lets the app read X-Forwarded-* from the proxy, FORCE_HTTPS redirects # any plain-HTTP request to HTTPS. COOKIE_SECURE: ${COOKIE_SECURE:-false} - TRUST_PROXY: ${TRUST_PROXY:-false} + TRUST_PROXY: ${TRUST_PROXY:-true} FORCE_HTTPS: ${FORCE_HTTPS:-false} UPLOAD_DIR: /data/uploads volumes: diff --git a/server/src/clientIp.js b/server/src/clientIp.js new file mode 100644 index 0000000..e7f9729 --- /dev/null +++ b/server/src/clientIp.js @@ -0,0 +1,30 @@ +export function normalizeClientIp(input) { + let ip = String(input ?? '').split(',')[0].trim(); + if (ip.startsWith('::ffff:')) ip = ip.slice(7); + if (ip.startsWith('[') && ip.includes(']')) ip = ip.slice(1, ip.indexOf(']')); + return ip.slice(0, 64); +} + +export function isPrivateClientIp(input) { + const ip = normalizeClientIp(input).toLowerCase(); + if (!ip) return false; + + const parts = ip.split('.').map(Number); + if (parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) { + return parts[0] === 10 + || parts[0] === 127 + || (parts[0] === 169 && parts[1] === 254) + || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) + || (parts[0] === 192 && parts[1] === 168); + } + + return ip === '::1' + || ip === '::' + || ip.startsWith('fc') + || ip.startsWith('fd') + || /^fe[89ab]/.test(ip); +} + +export function requestClientIp(req) { + return normalizeClientIp(req.ip || req.socket?.remoteAddress); +} diff --git a/server/src/config.js b/server/src/config.js index e704e79..48e4761 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -36,8 +36,9 @@ export const config = { }, jwtSecret, cookieSecure: process.env.COOKIE_SECURE === 'true', - // set to "true" only when running behind a reverse proxy (TLS termination) - trustProxy: process.env.TRUST_PROXY === 'true', + // The standard deployment has one reverse-proxy hop. Set this to false + // only when the app is deliberately exposed without a proxy. + trustProxy: process.env.TRUST_PROXY !== 'false', // when "true" (production behind a TLS proxy), plain-HTTP requests coming // through the proxy are 301-redirected to HTTPS. Direct requests without // an X-Forwarded-Proto header (e.g. the container healthcheck) are never diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index f44d7b9..a426ac9 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -24,6 +24,7 @@ import { buildInvitationEmail } from '../emailTemplate.js'; import { buildCustomEmail } from '../customEmailTemplate.js'; import { buildProcessingRegister, buildImageConsentForm } from '../complianceDocuments.js'; import { buildMembersPdf, buildMembersWorkbook } from '../memberDocuments.js'; +import { isPrivateClientIp, normalizeClientIp } from '../clientIp.js'; import { billingRouter } from './billing.js'; export const adminRouter = Router(); @@ -223,7 +224,18 @@ adminRouter.get('/members/:id/image-consent', validate(idParam, 'params'), async FROM image_consents WHERE member_id = $1 ORDER BY created_at DESC LIMIT 1`, [req.params.id] ); - res.json({ consent: result.rows[0] || null }); + const consent = result.rows[0] || null; + if (!consent) return res.json({ consent: null }); + + const storedIp = normalizeClientIp(consent.ip); + const ipUnavailable = Boolean(storedIp && isPrivateClientIp(storedIp)); + res.json({ + consent: { + ...consent, + ip: ipUnavailable ? '' : storedIp, + ip_unavailable: ipUnavailable, + }, + }); } catch (err) { next(err); } diff --git a/server/src/routes/member.js b/server/src/routes/member.js index 77cac58..8f50e5b 100644 --- a/server/src/routes/member.js +++ b/server/src/routes/member.js @@ -5,6 +5,7 @@ import { validate } from '../middleware/validate.js'; import { memberProfileSchema, imageConsentSchema } from '../schemas.js'; import { imageUpload, saveImage, deleteImage } from '../uploads.js'; import { IMAGE_CONSENT_VERSION } from '../imageConsent.js'; +import { requestClientIp } from '../clientIp.js'; export const memberRouter = Router(); @@ -114,7 +115,7 @@ memberRouter.post('/image-consent', validate(imageConsentSchema), async (req, re signatoryName, decision === 'accepted' ? signaturePng : null, IMAGE_CONSENT_VERSION, - req.ip || '', + requestClientIp(req), (req.headers['user-agent'] || '').slice(0, 400), ] ); diff --git a/web/src/components/admin/AdminTabs.jsx b/web/src/components/admin/AdminTabs.jsx index cf909e4..92af027 100644 --- a/web/src/components/admin/AdminTabs.jsx +++ b/web/src/components/admin/AdminTabs.jsx @@ -132,7 +132,10 @@ function ConsentRecordModal({ member, onClose }) {