discovery-media-player 0.1.130 → 0.1.131

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/serve.js CHANGED
@@ -119,7 +119,20 @@ const serveur = http.createServer(async (req, res) => {
119
119
 
120
120
  // Mode dossier : la racine du serveur montre ce qu'il y a à lire.
121
121
  if ((url.pathname === "/" || url.pathname === "/preview" || url.pathname === "/preview/") && process.env.PLAYER_LOCAL_ROOT) {
122
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
122
+ res.writeHead(200, {
123
+ "Content-Type": "text/html; charset=utf-8",
124
+ "Cache-Control": "no-store",
125
+ // ⚠️ Les MÊMES protections que la visionneuse, écrites pour la page qui n'y passait pas.
126
+ // C'était la seule page HTML servie sans CSP ni `nosniff`, encadrable par n'importe qui —
127
+ // relevé par le premier scan ZAP baseline (24/08). Elle n'a ni script, ni image, ni
128
+ // formulaire : tout est refusé, sauf son unique bloc <style>.
129
+ "X-Content-Type-Options": "nosniff",
130
+ "Referrer-Policy": "strict-origin-when-cross-origin",
131
+ // La définition vit dans le player (une seule copie du fait) — voir son commentaire.
132
+ "Permissions-Policy": player.POLITIQUE_PERMISSIONS,
133
+ "Content-Security-Policy":
134
+ "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
135
+ });
123
136
  res.end(await pageAccueil(path.resolve(process.env.PLAYER_LOCAL_ROOT)));
124
137
  return;
125
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.130",
3
+ "version": "0.1.131",
4
4
  "description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
5
5
  "keywords": [
6
6
  "pdf-viewer",
package/server/handler.js CHANGED
@@ -186,18 +186,38 @@ const { adresseAppelant } = require("./appelant");
186
186
  */
187
187
  const PLAFOND_RELAIS = Number(process.env.PLAYER_MAX_RELAY_BYTES || 0) || 60 * 1024 * 1024;
188
188
 
189
+ // ⚠️ UN REFUS EN TEXTE POSE SON TYPE, COMME TOUT LE RESTE. Ces réponses partaient avec un code et
190
+ // un corps, sans Content-Type : le seul corps de ce serveur qu'un navigateur avait le droit de
191
+ // deviner, alors que la règle `nosniff` est écrite partout ailleurs. Relevé par le premier scan
192
+ // ZAP baseline (règle 10019, 24/08).
193
+ function refuserEnTexte(res, statut, message) {
194
+ res.statusCode = statut;
195
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
196
+ res.setHeader("X-Content-Type-Options", "nosniff");
197
+ res.end(message);
198
+ }
199
+
200
+ // ⚠️ CE QUE LES PAGES N'UTILISENT PAS EST REFUSÉ PAR ÉCRIT. Sans Permissions-Policy, un script
201
+ // tiers compromis (le risque exact que l'inventaire TIERS épingle) hérite de tout ce que le
202
+ // navigateur sait faire — caméra, micro, position. Aucune page ici ne s'en sert : on le dit.
203
+ // Le plein écran n'est PAS refusé — la présentation s'en sert (page-visionneuse.js). Relevé par
204
+ // le premier passage CI du scan ZAP (règle 10063, 24/08) ; le passage local, scanners par
205
+ // défaut, ne l'avait pas vu. UNE définition, exportée : bin/serve.js pose la même — deux
206
+ // exemplaires d'un fait divergent, c'est la formule du dépôt.
207
+ const POLITIQUE_PERMISSIONS = "camera=(), microphone=(), geolocation=(), payment=()";
208
+
189
209
  async function relayerFichier(res, r, disposition) {
190
- if (!r) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
210
+ if (!r) { refuserEnTexte(res, 404, "Fichier indisponible"); return; }
191
211
  // 413 et 416 sont des REFUS ARGUMENTÉS de l'amont local (plafond, borne absurde) : les fondre
192
212
  // dans un 502 dirait « panne » là où l'amont a dit « demande irrecevable ».
193
213
  if (!r.ok && r.status !== 206) {
194
- res.statusCode = r.status === 413 || r.status === 416 ? r.status : 502;
195
- res.end(r.status === 413 ? "Fichier trop volumineux" : "Fichier indisponible");
214
+ refuserEnTexte(res, r.status === 413 || r.status === 416 ? r.status : 502,
215
+ r.status === 413 ? "Fichier trop volumineux" : "Fichier indisponible");
196
216
  return;
197
217
  }
198
218
 
199
219
  const compresse = !!r.headers.get("content-encoding");
200
- if (compresse && r.status === 206) { res.statusCode = 502; res.end("Fichier indisponible"); return; }
220
+ if (compresse && r.status === 206) { refuserEnTexte(res, 502, "Fichier indisponible"); return; }
201
221
 
202
222
  // ⚠️ DEUX BORNES, ET LA SECONDE EST LA SEULE QUI TIENNE DEVANT UN AMONT QUI MENT.
203
223
  //
@@ -213,8 +233,7 @@ async function relayerFichier(res, r, disposition) {
213
233
  const annoncee = Number(brute || 0);
214
234
  if (annoncee > PLAFOND_RELAIS) {
215
235
  try { PLAYER.errors.capture(new Error(`relais refusé : ${annoncee} octets au-dessus du plafond de ${PLAFOND_RELAIS}`), { route: "relais" }); } catch { /* jamais bloquant */ }
216
- res.statusCode = 413;
217
- res.end("Fichier trop volumineux");
236
+ refuserEnTexte(res, 413, "Fichier trop volumineux");
218
237
  // ⚠️ Renoncer ne suffit pas : un corps jamais tiré laisse la connexion amont OUVERTE, et le
219
238
  // pool de sockets s'épuise sur les gros fichiers — exactement la ressource qu'on protège.
220
239
  try { if (r.body) r.body.cancel(); } catch { /* déjà refermé */ }
@@ -296,6 +315,7 @@ function sendHtml(res, status, html, scriptSrc, imgExtra, frameAncestors) {
296
315
  res.setHeader("Cache-Control", "no-store, max-age=0");
297
316
  res.setHeader("X-Content-Type-Options", "nosniff");
298
317
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
318
+ res.setHeader("Permissions-Policy", POLITIQUE_PERMISSIONS);
299
319
  res.setHeader("Content-Security-Policy", [
300
320
  "default-src 'none'",
301
321
  // ⚠️ `'none'` DOIT ÊTRE SEUL ou la directive est invalide — « 'none' 'self' » a fait rejeter
@@ -339,6 +359,7 @@ function sendSoftWallHtml(res, html, nonce, imgExtra, frameAncestors) {
339
359
  res.setHeader("Cache-Control", "no-store, max-age=0");
340
360
  res.setHeader("X-Content-Type-Options", "nosniff");
341
361
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
362
+ res.setHeader("Permissions-Policy", POLITIQUE_PERMISSIONS);
342
363
  res.setHeader("Content-Security-Policy", [
343
364
  "default-src 'none'",
344
365
  `script-src 'nonce-${nonce}' https://accounts.google.com/gsi/client`,
@@ -427,6 +448,7 @@ function sendPresentHtml(res, html, nonce, supaUrl, imgExtra, frameAncestors) {
427
448
  res.setHeader("Cache-Control", "no-store, max-age=0");
428
449
  res.setHeader("X-Content-Type-Options", "nosniff");
429
450
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
451
+ res.setHeader("Permissions-Policy", POLITIQUE_PERMISSIONS);
430
452
  res.setHeader("Content-Security-Policy", [
431
453
  "default-src 'none'",
432
454
  `script-src 'nonce-${nonce}' 'self' https://cdn.jsdelivr.net https://unpkg.com https://maps.googleapis.com https://maps.gstatic.com`,
@@ -533,7 +555,7 @@ async function handler(req, res) {
533
555
  if (!octets) {
534
556
  try { octets = global.__actifsPdfjs[fichier] = require("node:fs").readFileSync(require.resolve(fichier)); } catch { /* dépendance absente */ }
535
557
  }
536
- if (!octets) { res.statusCode = 404; res.end("actif indisponible"); return; }
558
+ if (!octets) { refuserEnTexte(res, 404, "actif indisponible"); return; }
537
559
  res.statusCode = 200;
538
560
  res.setHeader("Content-Type", "text/javascript; charset=utf-8");
539
561
  res.setHeader("X-Content-Type-Options", "nosniff");
@@ -845,7 +867,7 @@ async function handler(req, res) {
845
867
  const pres = await getPresentation(String(q.present));
846
868
  if (!pres) return sendRefusal(res, "ended", embed);
847
869
  if (String(q.file || "") === "1") {
848
- if (!isAllowedStorageUrl(pres.file_url)) { res.statusCode = 404; res.end("Fichier indisponible"); return; }
870
+ if (!isAllowedStorageUrl(pres.file_url)) { refuserEnTexte(res, 404, "Fichier indisponible"); return; }
849
871
  const range = req.headers["range"];
850
872
  const r = await PLAYER.storage.fetchFile(pres.file_url, { range });
851
873
  await relayerFichier(res, r, null);
@@ -1013,6 +1035,6 @@ async function handler(req, res) {
1013
1035
  // tenir une seconde liste — c'est la seule façon qu'une empreinte périmée finisse par se voir.
1014
1036
  // ⚠️ Exporté pour être ÉPROUVÉ, pas pour être appelé : le plafond du relais ne se vérifie qu en
1015
1037
  // regardant si le corps a été lu, ce qu aucune route ne peut montrer de l extérieur.
1016
- module.exports = { handler, init, TIERS, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript };
1038
+ module.exports = { handler, init, TIERS, POLITIQUE_PERMISSIONS, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript };
1017
1039
 
1018
1040
  // redeploy: forcer le build production (Vercel a sauté la prod du merge #463 — wording re-partage).
@@ -139,7 +139,26 @@ async function traiter(req, res, body, _slug) {
139
139
  await PLAYER.db.request("doc_bot_sessions?id=eq." + encodeURIComponent(String(body.sessionId)), { method: "PATCH", headers: { Prefer: "return=minimal" }, body: cmt ? { rating: note, rating_comment: cmt } : { rating: note } });
140
140
  return jp(200, { ok: true });
141
141
  }
142
- if (body.action === "bot-history") return jp(200, { ok: true, messages: await docbot.listMessages(String(body.sessionId || "")) });
142
+ if (body.action === "bot-history") {
143
+ // ⚠️ MÊME LIAISON QUE `bot-rate` ET `bot-script`, ET ELLE MANQUAIT ICI.
144
+ //
145
+ // Cette action rendait le transcript de N'IMPORTE QUELLE session à qui en connaissait
146
+ // l'identifiant — et cet identifiant voyage côté client. Il suffisait d'un slug avec
147
+ // assistant activé (le refus juste au-dessus) et d'un `sessionId` récupéré ailleurs
148
+ // pour lire la conversation tenue sur un autre document : des questions de prospect,
149
+ // c'est-à-dire de la donnée commerciale sur un client, pas un compteur d'usage.
150
+ //
151
+ // ⚠️ ON NE PEUT PAS DÉLÉGUER CE FILTRAGE AU GREFFON. `docbot` est `ctx.plugins.bot`,
152
+ // fourni par l'hôte : supposer qu'il recoupe la session et le document reviendrait à
153
+ // faire dépendre une propriété de sécurité du player d'un code qu'il ne contient pas.
154
+ // C'est exactement pourquoi les deux actions voisines le vérifient elles-mêmes.
155
+ //
156
+ // Les deux voisines avaient la garde ET leur banc ; celle-ci n'avait ni l'une ni
157
+ // l'autre, et la relecture qui a ajouté les bancs a couvert deux actions sur trois.
158
+ const sess = await docbot.getSession(String(body.sessionId || ""));
159
+ if (!sess || sess.share_slug !== share.slug) return jp(400, { ok: false, error: "session" });
160
+ return jp(200, { ok: true, messages: await docbot.listMessages(String(body.sessionId || "")) });
161
+ }
143
162
  // ⚠️ Même piège : un objet littéral répond à `constructor`. Sans `Object.hasOwn`, une
144
163
  // langue « constructor » passait la garde et finissait interpolée dans le prompt du
145
164
  // modèle sous la forme « function Object() { [native code] } ».