mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
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:
10 files changed
+157
-41
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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 »."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'acceptent qu'une adresse de retour
|
||||
en <code>https</code> sur un nom de domaine : vérifiez-la puis enregistrez.
|
||||
</div>
|
||||
)}
|
||||
{!base && !detected && (
|
||||
|
||||
+4
-19
@@ -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> {
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { defaultProtocol, originFromHeaders } from "../src/lib/site-url";
|
||||
|
||||
function headersOf(entries: Record<string, string>) {
|
||||
const map = new Map(Object.entries(entries).map(([k, v]) => [k.toLowerCase(), v]));
|
||||
return { get: (name: string) => map.get(name.toLowerCase()) ?? null };
|
||||
}
|
||||
|
||||
test("l'origine suit les en-têtes du reverse proxy", () => {
|
||||
assert.equal(
|
||||
originFromHeaders(headersOf({ host: "pleinr-app:3000", "x-forwarded-host": "pleinr.example.fr", "x-forwarded-proto": "https" })),
|
||||
"https://pleinr.example.fr"
|
||||
);
|
||||
});
|
||||
|
||||
test("une liste d'hôtes transmis retient le premier", () => {
|
||||
assert.equal(
|
||||
originFromHeaders(headersOf({ "x-forwarded-host": "pleinr.example.fr, proxy.interne", "x-forwarded-proto": "https, http" })),
|
||||
"https://pleinr.example.fr"
|
||||
);
|
||||
});
|
||||
|
||||
test("un accès direct en HTTP sur une IP reste en HTTP", () => {
|
||||
assert.equal(originFromHeaders(headersOf({ host: "0.0.0.0:3000" })), "http://0.0.0.0:3000");
|
||||
assert.equal(originFromHeaders(headersOf({ host: "192.168.1.20:8413" })), "http://192.168.1.20:8413");
|
||||
assert.equal(originFromHeaders(headersOf({ host: "localhost:3000", "x-forwarded-proto": "http" })), "http://localhost:3000");
|
||||
});
|
||||
|
||||
test("un nom de domaine sans indication de protocole est présumé en HTTPS", () => {
|
||||
assert.equal(originFromHeaders(headersOf({ host: "pleinr.example.fr" })), "https://pleinr.example.fr");
|
||||
assert.equal(defaultProtocol("pleinr.example.fr:8443"), "https");
|
||||
assert.equal(defaultProtocol("[::1]:3000"), "http");
|
||||
});
|
||||
|
||||
test("un en-tête absent ou malformé ne produit rien", () => {
|
||||
assert.equal(originFromHeaders(headersOf({})), "");
|
||||
assert.equal(originFromHeaders(headersOf({ host: "bad host/with path" })), "");
|
||||
assert.equal(originFromHeaders(headersOf({ host: "pleinr.example.fr", "x-forwarded-proto": "ftp" })), "https://pleinr.example.fr");
|
||||
});
|
||||
Reference in new issue
Block a user