mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-20 21:58:09 +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.
|
aussi le cycle suspension → remise en ligne.
|
||||||
- `retryPromoShare` ne sert qu'au rattrapage d'un échec sur un réseau déjà
|
- `retryPromoShare` ne sert qu'au rattrapage d'un échec sur un réseau déjà
|
||||||
choisi ; il ne peut pas élargir la diffusion.
|
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`
|
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
|
||||||
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
|
(`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
|
posé, vérifier la délivrance avec
|
||||||
`docker logs pleinr-caddy 2>&1 | grep -i "certificate obtained"`.
|
`docker logs pleinr-caddy 2>&1 | grep -i "certificate obtained"`.
|
||||||
|
|
||||||
> **Vérification par l'IP** : en HTTP sur une adresse IP, la directive
|
> **Vérification par l'IP** : la directive `upgrade-insecure-requests` de la
|
||||||
> `upgrade-insecure-requests` de la CSP (`src/middleware.ts`) peut empêcher le
|
> CSP (`src/middleware.ts`) n'est posée que sur les pages servies en HTTPS. Un
|
||||||
> navigateur de charger styles et scripts. Pour un test fidèle avant le DNS,
|
> test en HTTP par l'IP ou par le port direct fonctionne donc sans tunnel. En
|
||||||
> passer par un tunnel SSH — `ssh -L 8080:localhost:80 utilisateur@IP` — puis
|
> revanche, Facebook et LinkedIn n'acceptent qu'une adresse de retour OAuth en
|
||||||
> ouvrir `http://localhost:8080` : `localhost` est une origine de confiance, la
|
> HTTPS sur un nom de domaine : la connexion des réseaux sociaux ne peut être
|
||||||
> directive ne s'y applique pas.
|
> validée qu'une fois le DNS posé.
|
||||||
|
|
||||||
Mise à jour : `git pull && docker compose up -d --build`.
|
Mise à jour : `git pull && docker compose up -d --build`.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
authorizeUrl,
|
authorizeUrl,
|
||||||
getDecryptedAppSecret,
|
getDecryptedAppSecret,
|
||||||
getSocialAccount,
|
getSocialAccount,
|
||||||
siteUrl,
|
publicBaseUrl,
|
||||||
SOCIAL_NETWORKS,
|
SOCIAL_NETWORKS,
|
||||||
type SocialNetwork,
|
type SocialNetwork,
|
||||||
} from "@/lib/social-accounts";
|
} from "@/lib/social-accounts";
|
||||||
@@ -34,11 +34,13 @@ export async function GET(
|
|||||||
return back(request, "Réseau inconnu.");
|
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) {
|
if (!base) {
|
||||||
return back(
|
return back(
|
||||||
request,
|
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 { headers } from "next/headers";
|
||||||
|
import { originFromHeaders } from "@/lib/site-url";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { getSession } from "@/lib/session";
|
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
|
// Adresse par laquelle l'administrateur consulte cette page : proposée par
|
||||||
// défaut pour lui éviter de la recopier à la main.
|
// défaut pour lui éviter de la recopier à la main.
|
||||||
const requestHeaders = await headers();
|
const detected = originFromHeaders(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}` : "";
|
|
||||||
|
|
||||||
// Calculées ici : le rendu des cartes n'est pas asynchrone.
|
// Calculées ici : le rendu des cartes n'est pas asynchrone.
|
||||||
const redirectUris = new Map<string, string>();
|
const redirectUris = new Map<string, string>();
|
||||||
@@ -124,8 +122,9 @@ export default async function ReseauxPage({
|
|||||||
</form>
|
</form>
|
||||||
{!base && detected && (
|
{!base && detected && (
|
||||||
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
|
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
|
||||||
Adresse détectée depuis votre navigation : <strong>{detected}</strong>. Vérifiez-la puis
|
Aucune adresse enregistrée : celle de votre navigation, <strong>{detected}</strong>, est
|
||||||
enregistrez.
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!base && !detected && (
|
{!base && !detected && (
|
||||||
|
|||||||
+4
-19
@@ -1,28 +1,13 @@
|
|||||||
import { headers } from "next/headers";
|
import { publicBaseUrl } from "@/lib/social-accounts";
|
||||||
import { siteUrl } from "@/lib/social-accounts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL publique du site pour le référencement (canoniques, sitemap, JSON-LD).
|
* 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
|
* Priorité au réglage `site_public_url` (Backend › Réseaux sociaux) puis aux
|
||||||
* variables d'environnement, via `siteUrl()`. En dernier recours, l'URL est
|
* variables d'environnement ; en dernier recours, l'URL est déduite des
|
||||||
* déduite des en-têtes de la requête (reverse proxy compris) : le site reste
|
* en-têtes de la requête (reverse proxy compris). Voir `publicBaseUrl()`.
|
||||||
* ainsi correctement référencé tant que le réglage n'est pas renseigné.
|
|
||||||
*/
|
*/
|
||||||
export async function publicBaseUrl(): Promise<string> {
|
export { publicBaseUrl };
|
||||||
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 "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `metadataBase` pour Next : indéfini si aucune URL n'est connue. */
|
/** `metadataBase` pour Next : indéfini si aucune URL n'est connue. */
|
||||||
export async function metadataBase(): Promise<URL | undefined> {
|
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 { eq } from "drizzle-orm";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { siteSettings, socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema";
|
import { siteSettings, socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema";
|
||||||
import { decryptSecret, encryptSecret } from "./crypto";
|
import { decryptSecret, encryptSecret } from "./crypto";
|
||||||
|
import { originFromHeaders } from "./site-url";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration des comptes Facebook / LinkedIn.
|
* 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(/\/+$/, "");
|
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> {
|
export async function redirectUri(network: SocialNetwork): Promise<string> {
|
||||||
return `${await siteUrl()}/api/social/${network}/callback`;
|
return `${await publicBaseUrl()}/api/social/${network}/callback`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Lecture ----
|
// ---- Lecture ----
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@
|
|||||||
import {
|
import {
|
||||||
refreshLinkedInIfNeeded,
|
refreshLinkedInIfNeeded,
|
||||||
resolveCredentials,
|
resolveCredentials,
|
||||||
siteUrl,
|
publicBaseUrl,
|
||||||
type SocialNetwork,
|
type SocialNetwork,
|
||||||
} from "./social-accounts";
|
} from "./social-accounts";
|
||||||
import { memberPath } from "./seo";
|
import { memberPath } from "./seo";
|
||||||
@@ -42,7 +42,7 @@ export type PromoForSharing = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
|
export async function promoLink(promo: PromoForSharing): Promise<string | null> {
|
||||||
const base = await siteUrl();
|
const base = await publicBaseUrl();
|
||||||
if (!base) return null;
|
if (!base) return null;
|
||||||
return promo.memberId
|
return promo.memberId
|
||||||
? `${base}${memberPath({ id: promo.memberId, name: promo.memberName ?? "", city: promo.memberCity })}`
|
? `${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
|
* `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.
|
* 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 [
|
return [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
@@ -35,14 +35,21 @@ function contentSecurityPolicy(nonce: string, isDev: boolean): string {
|
|||||||
"connect-src 'self'",
|
"connect-src 'self'",
|
||||||
// Carte « Nous situer » de la fiche adhérent.
|
// Carte « Nous situer » de la fiche adhérent.
|
||||||
"frame-src https://www.google.com https://maps.google.com",
|
"frame-src https://www.google.com https://maps.google.com",
|
||||||
"upgrade-insecure-requests",
|
// Uniquement quand la page est servie en HTTPS : sur une page HTTP (test
|
||||||
].join("; ");
|
// 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) => {
|
export default auth((request: NextRequest) => {
|
||||||
// `btoa` et non `Buffer` : le middleware tourne sur le runtime Edge.
|
// `btoa` et non `Buffer` : le middleware tourne sur le runtime Edge.
|
||||||
const nonce = btoa(crypto.randomUUID());
|
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 à
|
// 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.
|
// 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