Corrige la collecte de l'IP des consentements
This commit is contained in:
1 parent
9aff45ce8d
commit
65213e812a
8 files changed
+58
-11
No files matched your search
+2
-2
@@ -30,8 +30,8 @@ MEMBER_INITIAL_PASSWORD=ChangeMe-Membre-2026!
|
|||||||
# Adds the Secure flag on the session cookie (cookie sent over HTTPS only)
|
# Adds the Secure flag on the session cookie (cookie sent over HTTPS only)
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
# Lets the app trust X-Forwarded-* headers from the reverse proxy
|
# Trusts the single reverse-proxy hop so the app records the real client IP
|
||||||
TRUST_PROXY=false
|
TRUST_PROXY=true
|
||||||
|
|
||||||
# Redirects any plain-HTTP request (through the proxy) to HTTPS
|
# Redirects any plain-HTTP request (through the proxy) to HTTPS
|
||||||
FORCE_HTTPS=false
|
FORCE_HTTPS=false
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ proxy_pass http://sbc-app:8321;
|
|||||||
```
|
```
|
||||||
|
|
||||||
Ce réseau doit exister avant le déploiement (`docker network create nginx_default`
|
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
|
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-*`
|
`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
|
(`proxy_set_header Host $http_host;`). La base de données, elle, reste hors du
|
||||||
réseau du proxy.
|
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
|
### Pour la production
|
||||||
|
|
||||||
- Placez l'application derrière HTTPS (reverse-proxy TLS) et passez `COOKIE_SECURE=true`
|
- Placez l'application derrière HTTPS (reverse-proxy TLS), passez `COOKIE_SECURE=true`
|
||||||
et `TRUST_PROXY=true`.
|
et conservez `TRUST_PROXY=true`.
|
||||||
- Surchargez les valeurs par défaut (`POSTGRES_PASSWORD`, `APP_DB_PASSWORD`,
|
- Surchargez les valeurs par défaut (`POSTGRES_PASSWORD`, `APP_DB_PASSWORD`,
|
||||||
`JWT_SECRET`, mots de passe initiaux) et changez le mot de passe admin
|
`JWT_SECRET`, mots de passe initiaux) et changez le mot de passe admin
|
||||||
après la première connexion.
|
après la première connexion.
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@ services:
|
|||||||
# lets the app read X-Forwarded-* from the proxy, FORCE_HTTPS redirects
|
# lets the app read X-Forwarded-* from the proxy, FORCE_HTTPS redirects
|
||||||
# any plain-HTTP request to HTTPS.
|
# any plain-HTTP request to HTTPS.
|
||||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||||
TRUST_PROXY: ${TRUST_PROXY:-false}
|
TRUST_PROXY: ${TRUST_PROXY:-true}
|
||||||
FORCE_HTTPS: ${FORCE_HTTPS:-false}
|
FORCE_HTTPS: ${FORCE_HTTPS:-false}
|
||||||
UPLOAD_DIR: /data/uploads
|
UPLOAD_DIR: /data/uploads
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -36,8 +36,9 @@ export const config = {
|
|||||||
},
|
},
|
||||||
jwtSecret,
|
jwtSecret,
|
||||||
cookieSecure: process.env.COOKIE_SECURE === 'true',
|
cookieSecure: process.env.COOKIE_SECURE === 'true',
|
||||||
// set to "true" only when running behind a reverse proxy (TLS termination)
|
// The standard deployment has one reverse-proxy hop. Set this to false
|
||||||
trustProxy: process.env.TRUST_PROXY === 'true',
|
// 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
|
// when "true" (production behind a TLS proxy), plain-HTTP requests coming
|
||||||
// through the proxy are 301-redirected to HTTPS. Direct requests without
|
// through the proxy are 301-redirected to HTTPS. Direct requests without
|
||||||
// an X-Forwarded-Proto header (e.g. the container healthcheck) are never
|
// an X-Forwarded-Proto header (e.g. the container healthcheck) are never
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { buildInvitationEmail } from '../emailTemplate.js';
|
|||||||
import { buildCustomEmail } from '../customEmailTemplate.js';
|
import { buildCustomEmail } from '../customEmailTemplate.js';
|
||||||
import { buildProcessingRegister, buildImageConsentForm } from '../complianceDocuments.js';
|
import { buildProcessingRegister, buildImageConsentForm } from '../complianceDocuments.js';
|
||||||
import { buildMembersPdf, buildMembersWorkbook } from '../memberDocuments.js';
|
import { buildMembersPdf, buildMembersWorkbook } from '../memberDocuments.js';
|
||||||
|
import { isPrivateClientIp, normalizeClientIp } from '../clientIp.js';
|
||||||
import { billingRouter } from './billing.js';
|
import { billingRouter } from './billing.js';
|
||||||
|
|
||||||
export const adminRouter = Router();
|
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`,
|
FROM image_consents WHERE member_id = $1 ORDER BY created_at DESC LIMIT 1`,
|
||||||
[req.params.id]
|
[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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { validate } from '../middleware/validate.js';
|
|||||||
import { memberProfileSchema, imageConsentSchema } from '../schemas.js';
|
import { memberProfileSchema, imageConsentSchema } from '../schemas.js';
|
||||||
import { imageUpload, saveImage, deleteImage } from '../uploads.js';
|
import { imageUpload, saveImage, deleteImage } from '../uploads.js';
|
||||||
import { IMAGE_CONSENT_VERSION } from '../imageConsent.js';
|
import { IMAGE_CONSENT_VERSION } from '../imageConsent.js';
|
||||||
|
import { requestClientIp } from '../clientIp.js';
|
||||||
|
|
||||||
export const memberRouter = Router();
|
export const memberRouter = Router();
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ memberRouter.post('/image-consent', validate(imageConsentSchema), async (req, re
|
|||||||
signatoryName,
|
signatoryName,
|
||||||
decision === 'accepted' ? signaturePng : null,
|
decision === 'accepted' ? signaturePng : null,
|
||||||
IMAGE_CONSENT_VERSION,
|
IMAGE_CONSENT_VERSION,
|
||||||
req.ip || '',
|
requestClientIp(req),
|
||||||
(req.headers['user-agent'] || '').slice(0, 400),
|
(req.headers['user-agent'] || '').slice(0, 400),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -132,7 +132,10 @@ function ConsentRecordModal({ member, onClose }) {
|
|||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 13 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 13 }}>
|
||||||
<div><div style={{ fontSize: 11.5, color: 'var(--gray-light)' }}>Signataire</div>{consent.signatory_name}</div>
|
<div><div style={{ fontSize: 11.5, color: 'var(--gray-light)' }}>Signataire</div>{consent.signatory_name}</div>
|
||||||
<div><div style={{ fontSize: 11.5, color: 'var(--gray-light)' }}>Date</div>{new Date(consent.created_at).toLocaleString('fr-FR')}</div>
|
<div><div style={{ fontSize: 11.5, color: 'var(--gray-light)' }}>Date</div>{new Date(consent.created_at).toLocaleString('fr-FR')}</div>
|
||||||
<div><div style={{ fontSize: 11.5, color: 'var(--gray-light)' }}>Adresse IP</div>{consent.ip || '—'}</div>
|
<div>
|
||||||
|
<div style={{ fontSize: 11.5, color: 'var(--gray-light)' }}>Adresse IP</div>
|
||||||
|
{consent.ip_unavailable ? 'Non disponible (ancienne signature)' : (consent.ip || '—')}
|
||||||
|
</div>
|
||||||
<div><div style={{ fontSize: 11.5, color: 'var(--gray-light)' }}>Version</div>{consent.consent_version || '—'}</div>
|
<div><div style={{ fontSize: 11.5, color: 'var(--gray-light)' }}>Version</div>{consent.consent_version || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
{consent.signature_png && (
|
{consent.signature_png && (
|
||||||
|
|||||||
Reference in new issue
Block a user