Connexion réseaux : adresse de retour déduite de la requête, CSP sans upgrade en HTTP

Cliquer sur « Connecter » sans avoir enregistré l'URL publique du site
renvoyait une erreur, et la redirection retombait sur https://0.0.0.0 :
la directive upgrade-insecure-requests était posée même sur une page
servie en HTTP, le navigateur basculait donc la navigation vers un
https:// inexistant.

- `publicBaseUrl()` : réglage `site_public_url`, variables d'environnement,
  puis origine de la requête (X-Forwarded-Host / X-Forwarded-Proto). L'adresse
  de retour OAuth et le lien des publications l'utilisent ; `seo-server`
  s'appuie dessus au lieu de dupliquer la déduction.
- `src/lib/site-url.ts` : déduction pure et testée de l'origine, HTTP par
  défaut pour localhost et les IP nues, HTTPS pour un nom de domaine.
- Middleware : `upgrade-insecure-requests` uniquement quand la page est servie
  en HTTPS.
- Page Réseaux : l'adresse détectée est annoncée comme valeur par défaut, avec
  le rappel que Facebook et LinkedIn exigent du HTTPS sur un domaine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
Claude committed 2026-09-06 10:11:06 +00:00
1 parent 23d1d14a49
commit 17511a73c3
10 files changed
+157 -41

No files matched your search

@@ -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 »."
);
}
+5 -6
View File
@@ -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<string, string>();
@@ -124,8 +122,9 @@ export default async function ReseauxPage({
</form>
{!base && detected && (
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
Adresse détectée depuis votre navigation : <strong>{detected}</strong>. Vérifiez-la puis
enregistrez.
Aucune adresse enregistrée : celle de votre navigation, <strong>{detected}</strong>, est
utilisée par défaut. Facebook et LinkedIn n&apos;acceptent qu&apos;une adresse de retour
en <code>https</code> sur un nom de domaine : vérifiez-la puis enregistrez.
</div>
)}
{!base && !detected && (
+4 -19
View File
@@ -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<string> {
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<URL | undefined> {
+50
View File
@@ -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 "";
}
}
+24 -1
View File
@@ -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<string> {
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<string> {
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<string> {
return `${await siteUrl()}/api/social/${network}/callback`;
return `${await publicBaseUrl()}/api/social/${network}/callback`;
}
// ---- Lecture ----
+2 -2
View File
@@ -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<string | null> {
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 })}`
+11 -4
View File
@@ -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 <script>. Il est ensuite renvoyé sur la réponse au navigateur.