discovery-media-player 0.1.16 → 0.1.18

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
File without changes
@@ -218,15 +218,56 @@ function isAllowedStorageUrl(candidate, origins, hostBase, root) {
218
218
  * ⚠️ Le secret n'accompagne QUE la route de l'hôte. L'envoyer à un Storage public le ferait fuiter
219
219
  * dans les journaux d'un tiers, où il n'a rien à faire.
220
220
  */
221
+ // ⚠️ `fetch` SUIT LES REDIRECTIONS PAR DÉFAUT, ET N'EN REVALIDE AUCUNE.
222
+ //
223
+ // La garde ci-dessus n'examinait que l'URL de DÉPART. Un amont autorisé — la route de l'hôte, ou
224
+ // n'importe quelle origine de Storage listée — qui répond `302` emmenait donc l'appel où il
225
+ // voulait : `localhost`, une adresse privée, une API de métadonnées cloud. L'invariant annoncé
226
+ // dans le README (« no redirect following into your private network ») était faux.
227
+ //
228
+ // ⚠️ ET LE SECRET SUIVAIT. `fetch` ne retire que `Authorization`, `Cookie` et
229
+ // `Proxy-Authorization` lors d'une redirection inter-origines — un en-tête maison comme
230
+ // `x-player-fetch-secret` est transmis tel quel. Mesuré avec deux serveurs locaux avant de
231
+ // corriger : la destination recevait le secret partagé de l'hôte en clair. Ce n'est donc pas
232
+ // seulement une SSRF, c'est une exfiltration de la clé qui autorise à lire TOUS ses documents.
233
+ //
234
+ // On suit donc les sauts à la main. Trois propriétés, et chacune ferme une porte différente :
235
+ // 1. chaque saut repasse la garde complète — une redirection n'ouvre rien que l'URL de départ
236
+ // n'aurait pas ouvert ;
237
+ // 2. le secret est recalculé À CHAQUE SAUT — il ne part que si CE saut-là est sous la route de
238
+ // l'hôte, jamais parce que le premier l'était ;
239
+ // 3. le nombre de sauts est borné, et le protocole ne peut pas changer de nature : une
240
+ // redirection vers `file:` transformerait un amont distant en lecture de disque local.
241
+ const MAX_REDIRECTIONS = 5;
242
+ const DELAI_MAX_MS = 60_000;
243
+
221
244
  async function fetchAllowedFile(url, { range } = {}, { origins, hostBase, root, secret } = {}) {
222
245
  if (!isAllowedStorageUrl(url, origins || [], hostBase, root)) return null;
223
246
  const local = root && resolveLocal(url, root);
224
247
  if (local) return readLocal(local, range);
225
248
 
226
- const headers = { "accept-encoding": "identity" };
227
- if (range) headers.range = range;
228
- if (isHostFetchUrl(url, hostBase) && secret) headers["x-player-fetch-secret"] = secret;
229
- return fetch(url, { headers });
249
+ let cible = String(url);
250
+ for (let saut = 0; saut <= MAX_REDIRECTIONS; saut++) {
251
+ const headers = { "accept-encoding": "identity" };
252
+ if (range) headers.range = range;
253
+ // Recalculé à chaque tour : c'est CE saut-ci qui doit être sous la route de l'hôte.
254
+ if (isHostFetchUrl(cible, hostBase) && secret) headers["x-player-fetch-secret"] = secret;
255
+
256
+ // Un amont qui ne répond jamais immobiliserait la requête et ses ressources indéfiniment.
257
+ // Le délai est large — un gros document met du temps — mais il est borné.
258
+ const r = await fetch(cible, { headers, redirect: "manual", signal: AbortSignal.timeout(DELAI_MAX_MS) });
259
+ if (r.status < 300 || r.status > 399) return r;
260
+
261
+ const suivante = r.headers.get("location");
262
+ if (!suivante) return r; // 3xx sans destination : on rend la réponse telle quelle
263
+ let absolue;
264
+ try { absolue = new URL(suivante, cible); } catch { return null; }
265
+ // ⚠️ Jamais de changement de nature : `file:` ferait d'un amont distant une lecture de disque.
266
+ if (absolue.protocol !== "https:" && absolue.protocol !== "http:") return null;
267
+ if (!isAllowedStorageUrl(absolue.href, origins || [], hostBase, root)) return null;
268
+ cible = absolue.href;
269
+ }
270
+ return null; // boucle de redirections : on refuse plutôt que de tourner
230
271
  }
231
272
 
232
273
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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",
@@ -53,6 +53,7 @@
53
53
  "./package.json": "./package.json"
54
54
  },
55
55
  "scripts": {
56
+ "prepare": "node tools/install-hooks.mjs",
56
57
  "start": "node bin/serve.js",
57
58
  "build": "node build/bundle.mjs && tsc -p tsconfig.build.json && node -e \"import('./build/bundle.mjs').then(m=>m.marquerDistEsm())\"",
58
59
  "test": "vitest run",
package/server/handler.js CHANGED
@@ -1199,6 +1199,17 @@ function sendHtml(res, status, html, scriptSrc, imgExtra, frameAncestors) {
1199
1199
  `media-src 'self'${supaOrigin ? " " + supaOrigin : ""}`,
1200
1200
  "style-src 'unsafe-inline'",
1201
1201
  "base-uri 'none'",
1202
+ // ⚠️ `form-action` NE RETOMBE PAS SUR `default-src`. C'est l'une des rares directives à ne pas
1203
+ // hériter : une page en `default-src 'none'` peut malgré tout poster un formulaire n'importe
1204
+ // où. Aucune de nos pages ne contient de `<form>` — les envois passent par `fetch`, donc par
1205
+ // `connect-src` — mais un script injecté pourrait en fabriquer un pour exfiltrer vers un
1206
+ // domaine tiers, et rien ne l'en empêchait.
1207
+ //
1208
+ // `'self'` plutôt que `'none'` : le mur d'accès et la connexion visiteur peuvent avoir besoin
1209
+ // d'un envoi de même origine, et casser une authentification pour fermer une porte que
1210
+ // personne n'a franchie serait un mauvais échange. `'self'` ferme l'exfiltration, qui est le
1211
+ // risque réel.
1212
+ "form-action 'self'",
1202
1213
  // Aperçu interne : framing MÊME ORIGINE autorisé (iframe DocViewer). Page publique : 'none' (anti-clickjacking).
1203
1214
  `frame-ancestors ${frameAncestors || "'none'"}`,
1204
1215
  ].join("; "));
@@ -1220,6 +1231,17 @@ function sendSoftWallHtml(res, html, nonce, imgExtra, frameAncestors) {
1220
1231
  `img-src 'self' data: blob:${imgExtra ? " " + imgExtra : ""}`,
1221
1232
  "style-src 'unsafe-inline' https://accounts.google.com/gsi/style",
1222
1233
  "base-uri 'none'",
1234
+ // ⚠️ `form-action` NE RETOMBE PAS SUR `default-src`. C'est l'une des rares directives à ne pas
1235
+ // hériter : une page en `default-src 'none'` peut malgré tout poster un formulaire n'importe
1236
+ // où. Aucune de nos pages ne contient de `<form>` — les envois passent par `fetch`, donc par
1237
+ // `connect-src` — mais un script injecté pourrait en fabriquer un pour exfiltrer vers un
1238
+ // domaine tiers, et rien ne l'en empêchait.
1239
+ //
1240
+ // `'self'` plutôt que `'none'` : le mur d'accès et la connexion visiteur peuvent avoir besoin
1241
+ // d'un envoi de même origine, et casser une authentification pour fermer une porte que
1242
+ // personne n'a franchie serait un mauvais échange. `'self'` ferme l'exfiltration, qui est le
1243
+ // risque réel.
1244
+ "form-action 'self'",
1223
1245
  `frame-ancestors ${frameAncestors || "'self'"}`,
1224
1246
  ].join("; "));
1225
1247
  res.end(html);
@@ -1963,6 +1985,17 @@ function sendPresentHtml(res, html, nonce, supaUrl, imgExtra, frameAncestors) {
1963
1985
  "style-src 'unsafe-inline' https://unpkg.com https://fonts.googleapis.com",
1964
1986
  "font-src https://fonts.gstatic.com data:",
1965
1987
  "base-uri 'none'",
1988
+ // ⚠️ `form-action` NE RETOMBE PAS SUR `default-src`. C'est l'une des rares directives à ne pas
1989
+ // hériter : une page en `default-src 'none'` peut malgré tout poster un formulaire n'importe
1990
+ // où. Aucune de nos pages ne contient de `<form>` — les envois passent par `fetch`, donc par
1991
+ // `connect-src` — mais un script injecté pourrait en fabriquer un pour exfiltrer vers un
1992
+ // domaine tiers, et rien ne l'en empêchait.
1993
+ //
1994
+ // `'self'` plutôt que `'none'` : le mur d'accès et la connexion visiteur peuvent avoir besoin
1995
+ // d'un envoi de même origine, et casser une authentification pour fermer une porte que
1996
+ // personne n'a franchie serait un mauvais échange. `'self'` ferme l'exfiltration, qui est le
1997
+ // risque réel.
1998
+ "form-action 'self'",
1966
1999
  `frame-ancestors ${frameAncestors || "'none'"}`,
1967
2000
  ].join("; "));
1968
2001
  res.end(html);