diff --git a/CLAUDE.md b/CLAUDE.md index 0ecb77f..fbe9a54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,15 @@ site sans traitement supplémentaire. aussi le cycle suspension → remise en ligne. - `retryPromoShare` ne sert qu'au rattrapage d'un échec sur un réseau déjà choisi ; il ne peut pas élargir la diffusion. +- L'URL publique du site : `siteUrl()` lit le réglage `site_public_url` puis + `NEXT_PUBLIC_SITE_URL` / `AUTH_URL` ; `publicBaseUrl()` y ajoute un repli sur + l'origine de la requête (`src/lib/site-url.ts`, en-têtes `X-Forwarded-*`). + L'adresse de retour OAuth et les liens des publications passent par + `publicBaseUrl()` : la connexion marche sans réglage tant que l'admin passe + par l'adresse publique. +- La CSP (`src/middleware.ts`) ne pose `upgrade-insecure-requests` qu'en HTTPS, + sinon un test en HTTP par IP voit toutes ses navigations basculer vers un + `https://` inexistant. - Les URLs publiques des pages FB/LinkedIn sont des `site_settings` (`association_facebook`, `association_linkedin`), éditables dans Paramètres. diff --git a/README.md b/README.md index bb3635c..e2e0717 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,12 @@ vivent dans le volume `caddy_data` et se renouvellent seuls. Une fois le DNS posé, vérifier la délivrance avec `docker logs pleinr-caddy 2>&1 | grep -i "certificate obtained"`. -> **Vérification par l'IP** : en HTTP sur une adresse IP, la directive -> `upgrade-insecure-requests` de la CSP (`src/middleware.ts`) peut empêcher le -> navigateur de charger styles et scripts. Pour un test fidèle avant le DNS, -> passer par un tunnel SSH — `ssh -L 8080:localhost:80 utilisateur@IP` — puis -> ouvrir `http://localhost:8080` : `localhost` est une origine de confiance, la -> directive ne s'y applique pas. +> **Vérification par l'IP** : la directive `upgrade-insecure-requests` de la +> CSP (`src/middleware.ts`) n'est posée que sur les pages servies en HTTPS. Un +> test en HTTP par l'IP ou par le port direct fonctionne donc sans tunnel. En +> revanche, Facebook et LinkedIn n'acceptent qu'une adresse de retour OAuth en +> HTTPS sur un nom de domaine : la connexion des réseaux sociaux ne peut être +> validée qu'une fois le DNS posé. Mise à jour : `git pull && docker compose up -d --build`. diff --git a/src/app/api/social/[network]/connect/route.ts b/src/app/api/social/[network]/connect/route.ts index e781994..68925ec 100644 --- a/src/app/api/social/[network]/connect/route.ts +++ b/src/app/api/social/[network]/connect/route.ts @@ -6,7 +6,7 @@ import { authorizeUrl, getDecryptedAppSecret, getSocialAccount, - siteUrl, + publicBaseUrl, SOCIAL_NETWORKS, type SocialNetwork, } from "@/lib/social-accounts"; @@ -34,11 +34,13 @@ export async function GET( return back(request, "Réseau inconnu."); } - const base = await siteUrl(); + // Réglage enregistré, sinon l'adresse par laquelle l'administrateur est + // arrivé : c'est celle où le réseau nous renverra. + const base = await publicBaseUrl(); if (!base) { return back( request, - "Renseignez d'abord l'URL publique du site : elle sert à construire l'adresse de retour." + "Impossible de déterminer l'adresse publique du site : renseignez-la dans la section « URL publique du site »." ); } diff --git a/src/app/backend/reseaux/page.tsx b/src/app/backend/reseaux/page.tsx index c946108..27e28aa 100644 --- a/src/app/backend/reseaux/page.tsx +++ b/src/app/backend/reseaux/page.tsx @@ -1,4 +1,5 @@ import { headers } from "next/headers"; +import { originFromHeaders } from "@/lib/site-url"; import { redirect } from "next/navigation"; import type { CSSProperties } from "react"; import { getSession } from "@/lib/session"; @@ -61,10 +62,7 @@ export default async function ReseauxPage({ // Adresse par laquelle l'administrateur consulte cette page : proposée par // défaut pour lui éviter de la recopier à la main. - const requestHeaders = await headers(); - const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? ""; - const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https"); - const detected = host ? `${protocol}://${host}` : ""; + const detected = originFromHeaders(await headers()); // Calculées ici : le rendu des cartes n'est pas asynchrone. const redirectUris = new Map(); @@ -124,8 +122,9 @@ export default async function ReseauxPage({ {!base && detected && (
- Adresse détectée depuis votre navigation : {detected}. Vérifiez-la puis - enregistrez. + Aucune adresse enregistrée : celle de votre navigation, {detected}, est + utilisée par défaut. Facebook et LinkedIn n'acceptent qu'une adresse de retour + en https sur un nom de domaine : vérifiez-la puis enregistrez.
)} {!base && !detected && ( diff --git a/src/lib/seo-server.ts b/src/lib/seo-server.ts index 0101d96..3bd81e5 100644 --- a/src/lib/seo-server.ts +++ b/src/lib/seo-server.ts @@ -1,28 +1,13 @@ -import { headers } from "next/headers"; -import { siteUrl } from "@/lib/social-accounts"; +import { publicBaseUrl } from "@/lib/social-accounts"; /** * URL publique du site pour le référencement (canoniques, sitemap, JSON-LD). * * Priorité au réglage `site_public_url` (Backend › Réseaux sociaux) puis aux - * variables d'environnement, via `siteUrl()`. En dernier recours, l'URL est - * déduite des en-têtes de la requête (reverse proxy compris) : le site reste - * ainsi correctement référencé tant que le réglage n'est pas renseigné. + * variables d'environnement ; en dernier recours, l'URL est déduite des + * en-têtes de la requête (reverse proxy compris). Voir `publicBaseUrl()`. */ -export async function publicBaseUrl(): Promise { - const configured = await siteUrl().catch(() => ""); - if (configured) return configured; - try { - const h = await headers(); - const host = h.get("x-forwarded-host") ?? h.get("host") ?? ""; - if (!host) return ""; - const protocol = h.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https"); - return `${protocol}://${host}`.replace(/\/+$/, ""); - } catch { - // Hors requête (génération de robots/sitemap au build par exemple). - return ""; - } -} +export { publicBaseUrl }; /** `metadataBase` pour Next : indéfini si aucune URL n'est connue. */ export async function metadataBase(): Promise { diff --git a/src/lib/site-url.ts b/src/lib/site-url.ts new file mode 100644 index 0000000..15172b0 --- /dev/null +++ b/src/lib/site-url.ts @@ -0,0 +1,50 @@ +/** + * Déduction de l'origine publique du site à partir des en-têtes d'une requête. + * + * Sans dépendance à la base ni à Next : utilisable partout et testable. Sert de + * repli quand aucune URL publique n'est enregistrée dans Backend › Réseaux + * sociaux : derrière Caddy ou Nginx Proxy Manager, `X-Forwarded-Host` et + * `X-Forwarded-Proto` décrivent l'adresse vue par le visiteur. + */ + +type HeaderReader = { get(name: string): string | null }; + +const LOCAL_HOSTS = /^(localhost|.*\.localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\]|\[::\])$/i; +const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/; + +function first(value: string | null): string { + return (value ?? "").split(",")[0]?.trim() ?? ""; +} + +/** Nom d'hôte sans port, en minuscules. */ +function hostname(host: string): string { + const match = /^(\[[^\]]*\]|[^:]+)/.exec(host); + return (match?.[1] ?? host).toLowerCase(); +} + +/** + * Sans `X-Forwarded-Proto`, on présume HTTPS pour un nom de domaine — c'est le + * cas de tout déploiement réel — et HTTP pour une adresse locale ou une IP nue, + * qu'aucun certificat ne couvre. + */ +export function defaultProtocol(host: string): "http" | "https" { + const name = hostname(host); + if (!name || LOCAL_HOSTS.test(name) || IPV4.test(name)) return "http"; + return "https"; +} + +/** + * Origine (`https://pleinr.example.fr`) vue par le visiteur, ou chaîne vide si + * la requête ne porte aucun hôte. + */ +export function originFromHeaders(headers: HeaderReader): string { + const host = first(headers.get("x-forwarded-host")) || first(headers.get("host")); + if (!host || /[\s/\\]/.test(host)) return ""; + const forwarded = first(headers.get("x-forwarded-proto")).toLowerCase(); + const protocol = forwarded === "https" || forwarded === "http" ? forwarded : defaultProtocol(host); + try { + return new URL(`${protocol}://${host}`).origin; + } catch { + return ""; + } +} diff --git a/src/lib/social-accounts.ts b/src/lib/social-accounts.ts index 2b27221..fc02828 100644 --- a/src/lib/social-accounts.ts +++ b/src/lib/social-accounts.ts @@ -1,7 +1,9 @@ import { eq } from "drizzle-orm"; +import { headers } from "next/headers"; import { db } from "@/db"; import { siteSettings, socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema"; import { decryptSecret, encryptSecret } from "./crypto"; +import { originFromHeaders } from "./site-url"; /** * Configuration des comptes Facebook / LinkedIn. @@ -43,8 +45,29 @@ export async function siteUrl(): Promise { return (stored || env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, ""); } +/** + * URL publique effective : le réglage s'il existe, sinon l'adresse par laquelle + * la requête en cours est arrivée (en-têtes du reverse proxy compris). Chaîne + * vide hors requête si rien n'est configuré. + */ +export async function publicBaseUrl(): Promise { + const configured = await siteUrl().catch(() => ""); + if (configured) return configured; + try { + return originFromHeaders(await headers()); + } catch { + // Hors requête (génération de robots/sitemap au build par exemple). + return ""; + } +} + +/** + * Adresse de retour OAuth. Elle doit être identique au clic sur « Connecter » + * et au retour du réseau : les deux passent par le même hôte, la déduction + * depuis la requête est donc stable. + */ export async function redirectUri(network: SocialNetwork): Promise { - return `${await siteUrl()}/api/social/${network}/callback`; + return `${await publicBaseUrl()}/api/social/${network}/callback`; } // ---- Lecture ---- diff --git a/src/lib/social.ts b/src/lib/social.ts index ade7ab7..bceeda9 100644 --- a/src/lib/social.ts +++ b/src/lib/social.ts @@ -12,7 +12,7 @@ import { refreshLinkedInIfNeeded, resolveCredentials, - siteUrl, + publicBaseUrl, type SocialNetwork, } from "./social-accounts"; import { memberPath } from "./seo"; @@ -42,7 +42,7 @@ export type PromoForSharing = { }; export async function promoLink(promo: PromoForSharing): Promise { - const base = await siteUrl(); + const base = await publicBaseUrl(); if (!base) return null; return promo.memberId ? `${base}${memberPath({ id: promo.memberId, name: promo.memberName ?? "", city: promo.memberCity })}` diff --git a/src/middleware.ts b/src/middleware.ts index 6a73b08..2bb4fee 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -18,7 +18,7 @@ const { auth } = NextAuth(authConfig); * `style` et sur la feuille Google Fonts. C'est un compromis assumé — une * injection de style est sans commune mesure avec une injection de script. */ -function contentSecurityPolicy(nonce: string, isDev: boolean): string { +function contentSecurityPolicy(nonce: string, isDev: boolean, isHttps: boolean): string { return [ "default-src 'self'", "base-uri 'self'", @@ -35,14 +35,21 @@ function contentSecurityPolicy(nonce: string, isDev: boolean): string { "connect-src 'self'", // Carte « Nous situer » de la fiche adhérent. "frame-src https://www.google.com https://maps.google.com", - "upgrade-insecure-requests", - ].join("; "); + // Uniquement quand la page est servie en HTTPS : sur une page HTTP (test + // par IP avant la pose du DNS, accès direct au port), le navigateur + // basculerait toutes les navigations vers un https:// inexistant. + isHttps ? "upgrade-insecure-requests" : "", + ] + .filter(Boolean) + .join("; "); } export default auth((request: NextRequest) => { // `btoa` et non `Buffer` : le middleware tourne sur le runtime Edge. const nonce = btoa(crypto.randomUUID()); - const csp = contentSecurityPolicy(nonce, process.env.NODE_ENV !== "production"); + const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim(); + const isHttps = forwardedProto ? forwardedProto === "https" : request.nextUrl.protocol === "https:"; + const csp = contentSecurityPolicy(nonce, process.env.NODE_ENV !== "production", isHttps); // L'en-tête est posé sur la REQUÊTE : Next y lit le nonce pour l'appliquer à // ses balises