@miguelmorales13/nestkit 0.10.0 → 0.11.0

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/README.md CHANGED
@@ -857,6 +857,28 @@ van a la misma base, una sola clase implementa los dos y se atan con `useExistin
857
857
  `revokeIfActive` **tiene que ser atómico** —un UPDATE condicional, no un leer-y-escribir—: esa
858
858
  fila es lo único que impide que un refresh token robado se use dos veces.
859
859
 
860
+ **El `state` va incluido y no se puede apagar.** Sin él, cualquiera puede entregarle a una persona
861
+ una URL de callback que lleva un `code` de **su propia** cuenta del proveedor: el navegador de la
862
+ víctima completa el flujo y acaba dentro de la sesión del atacante, metiendo sus datos —su peso, su
863
+ dieta, lo que sea— en una cuenta que controla otro. Es el ataque que este parámetro existe para
864
+ cerrar, y es fácil no tenerlo sin notarlo, porque el login funciona igual de bien sin él.
865
+
866
+ `GET /auth/:provider` firma un `state` y lo deja en una cookie al salir; el callback exige que el
867
+ valor que devuelve el proveedor coincida con esa cookie, y lo comprueba **antes** de canjear el
868
+ `code` —si la llamada no salió de un acceso empezado en ese navegador, no hay por qué gastar el
869
+ `code` ni hablar con el proveedor—. La cookie se borra pase lo que pase, así que una pestaña
870
+ olvidada no sirve para repetir el callback más tarde.
871
+
872
+ Va firmado en vez de guardado en una tabla: no necesita esquema ni limpieza y caduca solo. Se
873
+ ajusta con `stateCookieName` y `stateTtlSeconds` (diez minutos por defecto: da para escribir una
874
+ contraseña y pasar un segundo factor, y deja sin valor a una URL filtrada para cuando alguien la
875
+ encuentre).
876
+
877
+ > **Incompatible desde 0.11.0.** `authorizeUrl(provider)` devolvía la URL como string y ahora
878
+ > devuelve `{ url, state, expiresAt }` — el `state` hay que escribirlo en una cookie. Si usás
879
+ > `createOAuthController()` no tenés que hacer nada, ya lo hace. Solo afecta a quien llame al
880
+ > servicio desde un controlador propio.
881
+
860
882
  Personalizable sin tocar la librería: nombre y path de la cookie, TTLs, `sameSite`, a dónde vuelve
861
883
  el navegador tras entrar / fallar / vincular, si un login cierra las demás sesiones
862
884
  (`singleSession`, por defecto sí), qué campos del usuario salen por la API (`mapUser`), el guard de
@@ -985,28 +1007,52 @@ MediaModule.forRoot({
985
1007
  publicUrl: 'https://api.tuapp.com/api', // ¡con el prefijo global!
986
1008
  uploadSecret: process.env.MEDIA_SECRET,
987
1009
  ttlMs: 60 * 60 * 1000, // por defecto una hora
1010
+ // Por defecto: png, jpeg, webp y gif. Ampliá solo con formatos que no
1011
+ // puedan ejecutar nada (ver abajo por qué SVG no está en la lista).
1012
+ allowedContentTypes: ['image/png', 'image/jpeg'],
1013
+ limits: { maxFiles: 50, maxTotalBytes: 256 * 1024 * 1024 }, // los de por defecto
988
1014
  })
989
1015
  ```
990
1016
 
991
1017
  Expone `POST /media` (devuelve `{ url }`) y `GET /media/:id`. La subida va protegida con un secreto
992
1018
  compartido en la cabecera `x-media-secret`, no con un JWT, porque quien llama es un job de CI y no
993
- una persona con sesión; sin él cualquiera podría alojar contenido arbitrario en tu dominio. La
1019
+ una persona con sesión; sin él cualquiera podría alojar contenido arbitrario en tu dominio. Se
1020
+ compara en tiempo constante: un `!==` corta en el primer byte distinto, así que lo que tarda
1021
+ delata cuánto se acertó. La
994
1022
  descarga es pública a propósito: los servicios que consumen estas URLs las piden sin credenciales.
995
1023
 
996
1024
  **Vive en memoria, a propósito.** Entre guardar y publicar pasan segundos, así que un reinicio en el
997
1025
  medio solo significa reintentar, y no queda nada en disco. Si necesitás que los archivos sobrevivan
998
1026
  a un reinicio, esta es la herramienta equivocada: usá `storage`.
999
1027
 
1028
+ Y por vivir en memoria, **tiene techo**: tope de archivos y de bytes, porque esa memoria es el
1029
+ montículo del proceso y las entradas duran una hora. Sin límite, un bucle contra el endpoint de
1030
+ subida —tanto un fallo de un job de publicación como alguien con el secreto— lo hace crecer hasta
1031
+ que el proceso muere, y se lleva por delante la API entera, no solo las subidas. Al llegar al tope
1032
+ **rechaza en vez de desalojar lo más viejo**: desalojar rompería en silencio una publicación en
1033
+ curso, y quien la lanzó no puede saber que su imagen desapareció hasta que el servicio remoto
1034
+ responde un 404 que no se explica.
1035
+
1036
+ **No acepta SVG, y no es un olvido.** Un SVG es un documento que puede llevar script, y servido
1037
+ desde tu dominio ese script corre **en el origen de tu aplicación**: sus cookies, su
1038
+ almacenamiento. Es un XSS almacenado que regala el propio alojador de archivos. Por eso el tipo
1039
+ declarado se coteja contra una lista en vez de tomarse por bueno, y al servir se manda
1040
+ `x-content-type-options: nosniff` para que el navegador tampoco decida por su cuenta.
1041
+
1000
1042
  Dos errores que cuestan una tarde:
1001
1043
 
1002
1044
  - `publicUrl` **tiene que incluir el prefijo global** de la app. Sin él la URL cae en lo que sirva el
1003
1045
  frontend, que responde 200 con una página HTML, y el consumidor se descarga eso en vez del archivo.
1004
1046
  - Fastify solo parsea JSON de fábrica. Sin registrar un parser para los tipos que vas a aceptar, la
1005
- subida responde 415:
1047
+ subida responde 415. **Listá los tipos, no uses `/^image\//`** —que es lo que decía este
1048
+ ejemplo hasta 0.11.0—: ese patrón deja pasar `image/svg+xml`, y aunque el módulo ahora lo
1049
+ rechace, es mejor que ni siquiera llegue a parsearse:
1006
1050
 
1007
1051
  ```ts
1008
1052
  app.getHttpAdapter().getInstance().addContentTypeParser(
1009
- /^image\//, { parseAs: 'buffer' }, (_req, body, done) => done(null, body),
1053
+ ['image/png', 'image/jpeg', 'image/webp'],
1054
+ { parseAs: 'buffer', bodyLimit: 12 * 1024 * 1024 },
1055
+ (_req, body, done) => done(null, body),
1010
1056
  )
1011
1057
  ```
1012
1058
 
@@ -1083,7 +1129,7 @@ Qué se añadió en cada versión y por qué está hecho así: [CAMBIOS.md](./CA
1083
1129
 
1084
1130
  ## Estado del paquete
1085
1131
 
1086
- `0.10.0`. Sin adaptador Mongo real. Sin tests unitarios propios.
1132
+ `0.11.0`. Sin adaptador Mongo real. Sin tests unitarios propios.
1087
1133
 
1088
1134
  `auth/oauth` y `umami` sí tienen un consumidor real: **nutrimx** corre su acceso con Google y
1089
1135
  Facebook sobre este módulo en producción. El resto sigue sin ejercitarse de verdad.
@@ -141,9 +141,11 @@ function resolveOAuthOptions(options) {
141
141
  secure: _nullishCoalesce(_optionalChain([options, 'access', _9 => _9.cookie, 'optionalAccess', _10 => _10.secure]), () => ( process.env.NODE_ENV === "production"))
142
142
  },
143
143
  linkCookieName: _nullishCoalesce(options.linkCookieName, () => ( "link_intent")),
144
+ stateCookieName: _nullishCoalesce(options.stateCookieName, () => ( "oauth_state")),
144
145
  accessTtlSeconds: _nullishCoalesce(options.accessTtlSeconds, () => ( 15 * 60)),
145
146
  refreshTtlSeconds: _nullishCoalesce(options.refreshTtlSeconds, () => ( 30 * 24 * 60 * 60)),
146
147
  linkTtlSeconds: _nullishCoalesce(options.linkTtlSeconds, () => ( 10 * 60)),
148
+ stateTtlSeconds: _nullishCoalesce(options.stateTtlSeconds, () => ( 10 * 60)),
147
149
  accessSecret: options.accessSecret,
148
150
  refreshSecret: options.refreshSecret,
149
151
  singleSession: _nullishCoalesce(options.singleSession, () => ( true)),
@@ -192,8 +194,60 @@ var OAuthAuthService = class {
192
194
  }
193
195
  return found;
194
196
  }
197
+ /**
198
+ * The provider's consent URL, together with the `state` that has to travel
199
+ * back with the callback.
200
+ *
201
+ * `state` is not decoration. Without it, anyone can hand a victim a
202
+ * callback URL carrying an authorization code for *their own* provider
203
+ * account: the victim's browser completes the flow and ends up signed in as
204
+ * the attacker, entering their data into an account someone else controls.
205
+ * The value goes out in the URL and, signed, into a cookie; the callback
206
+ * proceeds only when the two match, which a third party cannot arrange
207
+ * because they cannot write cookies for this domain.
208
+ *
209
+ * Signed rather than stored in a table: it needs no schema and no cleanup,
210
+ * and it expires on its own.
211
+ */
195
212
  authorizeUrl(name) {
196
- return buildAuthorizeUrl(this.provider(name));
213
+ const provider = this.provider(name);
214
+ const ttl = this.options.stateTtlSeconds;
215
+ const state = _jsonwebtoken2.default.sign(
216
+ // `nonce` makes two authorize requests within the same second produce
217
+ // different tokens; `typ` keeps this from being swapped with the link
218
+ // token, which is signed with the same secret.
219
+ { typ: "state", provider: provider.name, nonce: _crypto.randomUUID.call(void 0, ) },
220
+ requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET"),
221
+ { expiresIn: ttl }
222
+ );
223
+ return {
224
+ url: buildAuthorizeUrl(provider, state),
225
+ state,
226
+ expiresAt: new Date(Date.now() + ttl * 1e3)
227
+ };
228
+ }
229
+ /**
230
+ * Whether the `state` the provider sent back is the one issued for this
231
+ * provider, to this browser, and still valid.
232
+ *
233
+ * Both halves are checked. Matching the cookie proves the callback belongs
234
+ * to the authorize request this browser started; verifying the signature
235
+ * proves the pair was not simply invented by whoever crafted the URL.
236
+ */
237
+ verifyState(received, fromCookie, provider) {
238
+ if (!received || !fromCookie) return false;
239
+ const a = Buffer.from(received);
240
+ const b = Buffer.from(fromCookie);
241
+ if (a.length !== b.length || !_crypto.timingSafeEqual.call(void 0, a, b)) return false;
242
+ try {
243
+ const payload = _jsonwebtoken2.default.verify(
244
+ received,
245
+ requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET")
246
+ );
247
+ return payload.typ === "state" && payload.provider === provider;
248
+ } catch (e2) {
249
+ return false;
250
+ }
197
251
  }
198
252
  /** Exchanges the callback's `code` for a normalised profile. */
199
253
  async profileFromCode(name, code) {
@@ -303,7 +357,7 @@ var OAuthAuthService = class {
303
357
  requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET")
304
358
  );
305
359
  return payload.typ === "link" && payload.sub ? payload.sub : null;
306
- } catch (e2) {
360
+ } catch (e3) {
307
361
  return null;
308
362
  }
309
363
  }
@@ -388,7 +442,7 @@ var OAuthAuthService = class {
388
442
  token,
389
443
  requireSecret(this.options.refreshSecret, "JWT_REFRESH_SECRET")
390
444
  );
391
- } catch (e3) {
445
+ } catch (e4) {
392
446
  throw new (0, _chunkFDNGAYTZcjs.UnauthorizedAppException)("Invalid refresh token.");
393
447
  }
394
448
  }
@@ -491,12 +545,24 @@ function createOAuthController(options = {}) {
491
545
  return { ok: true };
492
546
  }
493
547
  start(provider, reply) {
494
- redirect(reply, this.auth.authorizeUrl(provider));
548
+ const { url, state, expiresAt } = this.auth.authorizeUrl(provider);
549
+ setCookie(reply, this.auth.config.stateCookieName, state, {
550
+ ...this.cookieBase(),
551
+ expires: expiresAt
552
+ });
553
+ redirect(reply, url);
495
554
  }
496
- async callback(provider, code, request, reply) {
555
+ async callback(provider, code, state, request, reply) {
497
556
  if (!code) throw new (0, _chunkFDNGAYTZcjs.UnauthorizedAppException)(this.auth.config.genericErrorMessage());
557
+ const stateCookie = _optionalChain([request, 'access', _19 => _19.cookies, 'optionalAccess', _20 => _20[this.auth.config.stateCookieName]]);
558
+ clearCookie(reply, this.auth.config.stateCookieName, {
559
+ path: this.auth.config.cookie.path
560
+ });
561
+ if (!this.auth.verifyState(state, stateCookie, provider)) {
562
+ throw new (0, _chunkFDNGAYTZcjs.UnauthorizedAppException)(this.auth.config.genericErrorMessage());
563
+ }
498
564
  const profile = await this.auth.profileFromCode(provider, code);
499
- const linkUserId = this.auth.readLinkToken(_optionalChain([request, 'access', _19 => _19.cookies, 'optionalAccess', _20 => _20[this.auth.config.linkCookieName]]));
565
+ const linkUserId = this.auth.readLinkToken(_optionalChain([request, 'access', _21 => _21.cookies, 'optionalAccess', _22 => _22[this.auth.config.linkCookieName]]));
500
566
  if (linkUserId) {
501
567
  clearCookie(reply, this.auth.config.linkCookieName, { path: this.auth.config.cookie.path });
502
568
  try {
@@ -562,8 +628,9 @@ function createOAuthController(options = {}) {
562
628
  _common.UseFilters.call(void 0, OAuthCallbackFilter),
563
629
  _chunk2REOCMUDcjs.__decorateParam.call(void 0, 0, _common.Param.call(void 0, "provider")),
564
630
  _chunk2REOCMUDcjs.__decorateParam.call(void 0, 1, _common.Query.call(void 0, "code")),
565
- _chunk2REOCMUDcjs.__decorateParam.call(void 0, 2, _common.Req.call(void 0, )),
566
- _chunk2REOCMUDcjs.__decorateParam.call(void 0, 3, _common.Res.call(void 0, ))
631
+ _chunk2REOCMUDcjs.__decorateParam.call(void 0, 2, _common.Query.call(void 0, "state")),
632
+ _chunk2REOCMUDcjs.__decorateParam.call(void 0, 3, _common.Req.call(void 0, )),
633
+ _chunk2REOCMUDcjs.__decorateParam.call(void 0, 4, _common.Res.call(void 0, ))
567
634
  ], OAuthControllerHost.prototype, "callback", 1);
568
635
  OAuthControllerHost = _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
569
636
  _common.Controller.call(void 0, _nullishCoalesce(options.path, () => ( "auth")))
@@ -141,9 +141,11 @@ function resolveOAuthOptions(options) {
141
141
  secure: options.cookie?.secure ?? process.env.NODE_ENV === "production"
142
142
  },
143
143
  linkCookieName: options.linkCookieName ?? "link_intent",
144
+ stateCookieName: options.stateCookieName ?? "oauth_state",
144
145
  accessTtlSeconds: options.accessTtlSeconds ?? 15 * 60,
145
146
  refreshTtlSeconds: options.refreshTtlSeconds ?? 30 * 24 * 60 * 60,
146
147
  linkTtlSeconds: options.linkTtlSeconds ?? 10 * 60,
148
+ stateTtlSeconds: options.stateTtlSeconds ?? 10 * 60,
147
149
  accessSecret: options.accessSecret,
148
150
  refreshSecret: options.refreshSecret,
149
151
  singleSession: options.singleSession ?? true,
@@ -157,7 +159,7 @@ function resolveOAuthOptions(options) {
157
159
 
158
160
  // src/auth/oauth/oauth-auth.service.ts
159
161
  import { Inject, Injectable, ServiceUnavailableException } from "@nestjs/common";
160
- import { createHash, randomUUID } from "crypto";
162
+ import { createHash, randomUUID, timingSafeEqual } from "crypto";
161
163
  import jwt from "jsonwebtoken";
162
164
  function hashToken(token) {
163
165
  return createHash("sha256").update(token).digest("hex");
@@ -192,8 +194,60 @@ var OAuthAuthService = class {
192
194
  }
193
195
  return found;
194
196
  }
197
+ /**
198
+ * The provider's consent URL, together with the `state` that has to travel
199
+ * back with the callback.
200
+ *
201
+ * `state` is not decoration. Without it, anyone can hand a victim a
202
+ * callback URL carrying an authorization code for *their own* provider
203
+ * account: the victim's browser completes the flow and ends up signed in as
204
+ * the attacker, entering their data into an account someone else controls.
205
+ * The value goes out in the URL and, signed, into a cookie; the callback
206
+ * proceeds only when the two match, which a third party cannot arrange
207
+ * because they cannot write cookies for this domain.
208
+ *
209
+ * Signed rather than stored in a table: it needs no schema and no cleanup,
210
+ * and it expires on its own.
211
+ */
195
212
  authorizeUrl(name) {
196
- return buildAuthorizeUrl(this.provider(name));
213
+ const provider = this.provider(name);
214
+ const ttl = this.options.stateTtlSeconds;
215
+ const state = jwt.sign(
216
+ // `nonce` makes two authorize requests within the same second produce
217
+ // different tokens; `typ` keeps this from being swapped with the link
218
+ // token, which is signed with the same secret.
219
+ { typ: "state", provider: provider.name, nonce: randomUUID() },
220
+ requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET"),
221
+ { expiresIn: ttl }
222
+ );
223
+ return {
224
+ url: buildAuthorizeUrl(provider, state),
225
+ state,
226
+ expiresAt: new Date(Date.now() + ttl * 1e3)
227
+ };
228
+ }
229
+ /**
230
+ * Whether the `state` the provider sent back is the one issued for this
231
+ * provider, to this browser, and still valid.
232
+ *
233
+ * Both halves are checked. Matching the cookie proves the callback belongs
234
+ * to the authorize request this browser started; verifying the signature
235
+ * proves the pair was not simply invented by whoever crafted the URL.
236
+ */
237
+ verifyState(received, fromCookie, provider) {
238
+ if (!received || !fromCookie) return false;
239
+ const a = Buffer.from(received);
240
+ const b = Buffer.from(fromCookie);
241
+ if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
242
+ try {
243
+ const payload = jwt.verify(
244
+ received,
245
+ requireSecret(this.options.accessSecret, "JWT_ACCESS_SECRET")
246
+ );
247
+ return payload.typ === "state" && payload.provider === provider;
248
+ } catch {
249
+ return false;
250
+ }
197
251
  }
198
252
  /** Exchanges the callback's `code` for a normalised profile. */
199
253
  async profileFromCode(name, code) {
@@ -491,10 +545,22 @@ function createOAuthController(options = {}) {
491
545
  return { ok: true };
492
546
  }
493
547
  start(provider, reply) {
494
- redirect(reply, this.auth.authorizeUrl(provider));
548
+ const { url, state, expiresAt } = this.auth.authorizeUrl(provider);
549
+ setCookie(reply, this.auth.config.stateCookieName, state, {
550
+ ...this.cookieBase(),
551
+ expires: expiresAt
552
+ });
553
+ redirect(reply, url);
495
554
  }
496
- async callback(provider, code, request, reply) {
555
+ async callback(provider, code, state, request, reply) {
497
556
  if (!code) throw new UnauthorizedAppException(this.auth.config.genericErrorMessage());
557
+ const stateCookie = request.cookies?.[this.auth.config.stateCookieName];
558
+ clearCookie(reply, this.auth.config.stateCookieName, {
559
+ path: this.auth.config.cookie.path
560
+ });
561
+ if (!this.auth.verifyState(state, stateCookie, provider)) {
562
+ throw new UnauthorizedAppException(this.auth.config.genericErrorMessage());
563
+ }
498
564
  const profile = await this.auth.profileFromCode(provider, code);
499
565
  const linkUserId = this.auth.readLinkToken(request.cookies?.[this.auth.config.linkCookieName]);
500
566
  if (linkUserId) {
@@ -562,8 +628,9 @@ function createOAuthController(options = {}) {
562
628
  UseFilters(OAuthCallbackFilter),
563
629
  __decorateParam(0, Param("provider")),
564
630
  __decorateParam(1, Query("code")),
565
- __decorateParam(2, Req()),
566
- __decorateParam(3, Res())
631
+ __decorateParam(2, Query("state")),
632
+ __decorateParam(3, Req()),
633
+ __decorateParam(4, Res())
567
634
  ], OAuthControllerHost.prototype, "callback", 1);
568
635
  OAuthControllerHost = __decorateClass([
569
636
  Controller(options.path ?? "auth")
@@ -29,7 +29,35 @@ export declare class OAuthAuthService<U extends OAuthUser = OAuthUser> {
29
29
  * differently, and distinguishing them only tells a prober what exists.
30
30
  */
31
31
  provider(name: string): OAuthProviderConfig;
32
- authorizeUrl(name: string): string;
32
+ /**
33
+ * The provider's consent URL, together with the `state` that has to travel
34
+ * back with the callback.
35
+ *
36
+ * `state` is not decoration. Without it, anyone can hand a victim a
37
+ * callback URL carrying an authorization code for *their own* provider
38
+ * account: the victim's browser completes the flow and ends up signed in as
39
+ * the attacker, entering their data into an account someone else controls.
40
+ * The value goes out in the URL and, signed, into a cookie; the callback
41
+ * proceeds only when the two match, which a third party cannot arrange
42
+ * because they cannot write cookies for this domain.
43
+ *
44
+ * Signed rather than stored in a table: it needs no schema and no cleanup,
45
+ * and it expires on its own.
46
+ */
47
+ authorizeUrl(name: string): {
48
+ url: string;
49
+ state: string;
50
+ expiresAt: Date;
51
+ };
52
+ /**
53
+ * Whether the `state` the provider sent back is the one issued for this
54
+ * provider, to this browser, and still valid.
55
+ *
56
+ * Both halves are checked. Matching the cookie proves the callback belongs
57
+ * to the authorize request this browser started; verifying the signature
58
+ * proves the pair was not simply invented by whoever crafted the URL.
59
+ */
60
+ verifyState(received: string | undefined, fromCookie: string | undefined, provider: string): boolean;
33
61
  /** Exchanges the callback's `code` for a normalised profile. */
34
62
  profileFromCode(name: string, code: string): Promise<OAuthProfile>;
35
63
  /**
@@ -41,10 +41,21 @@ export interface OAuthAuthOptions<U extends OAuthUser = OAuthUser> {
41
41
  * already signed-in account. Default `link_intent`.
42
42
  */
43
43
  linkCookieName?: string;
44
+ /**
45
+ * Cookie holding the `state` that ties an authorize request to its
46
+ * callback. Defaults to `oauth_state`.
47
+ */
48
+ stateCookieName?: string;
44
49
  /** Defaults: 15 min access, 30 days refresh, 10 min link permission. */
45
50
  accessTtlSeconds?: number;
46
51
  refreshTtlSeconds?: number;
47
52
  linkTtlSeconds?: number;
53
+ /**
54
+ * How long a `state` stays valid. Defaults to ten minutes — long enough to
55
+ * type a password and pass a second factor, short enough that a leaked
56
+ * authorize URL is worthless by the time anyone finds it.
57
+ */
58
+ stateTtlSeconds?: number;
48
59
  /** Read from `JWT_ACCESS_SECRET` / `JWT_REFRESH_SECRET` when omitted. */
49
60
  accessSecret?: string;
50
61
  refreshSecret?: string;
package/dist/index.cjs CHANGED
@@ -3,12 +3,12 @@
3
3
 
4
4
 
5
5
 
6
- var _chunkSPTDXUEDcjs = require('./chunk-SPTDXUED.cjs');
6
+ var _chunkRF75KC63cjs = require('./chunk-RF75KC63.cjs');
7
7
 
8
8
 
9
9
 
10
10
 
11
- var _chunkRF75KC63cjs = require('./chunk-RF75KC63.cjs');
11
+ var _chunkSPTDXUEDcjs = require('./chunk-SPTDXUED.cjs');
12
12
  require('./chunk-MR2IFCZE.cjs');
13
13
 
14
14
 
@@ -27,9 +27,6 @@ var _chunkTJHRABMLcjs = require('./chunk-TJHRABML.cjs');
27
27
  var _chunk3CLYZC3Tcjs = require('./chunk-3CLYZC3T.cjs');
28
28
 
29
29
 
30
- var _chunkQS2W5XCQcjs = require('./chunk-QS2W5XCQ.cjs');
31
-
32
-
33
30
 
34
31
  var _chunkM3EL5O6Tcjs = require('./chunk-M3EL5O6T.cjs');
35
32
  require('./chunk-7SOM7EZP.cjs');
@@ -49,6 +46,9 @@ var _chunkZA56XBCKcjs = require('./chunk-ZA56XBCK.cjs');
49
46
 
50
47
 
51
48
  var _chunkR7BVS6CIcjs = require('./chunk-R7BVS6CI.cjs');
49
+
50
+
51
+ var _chunkQS2W5XCQcjs = require('./chunk-QS2W5XCQ.cjs');
52
52
  require('./chunk-2REOCMUD.cjs');
53
53
 
54
54
 
package/dist/index.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import "./chunk-EPVKCBPT.js";
2
- import {
3
- SUPABASE_ANON_CLIENT,
4
- SUPABASE_SERVICE_ROLE_CLIENT,
5
- SupabaseModule
6
- } from "./chunk-PA24P76K.js";
7
2
  import {
8
3
  PG_POOL,
9
4
  PgModule,
10
5
  withTenantScope
11
6
  } from "./chunk-VKOPDDCC.js";
7
+ import {
8
+ SUPABASE_ANON_CLIENT,
9
+ SUPABASE_SERVICE_ROLE_CLIENT,
10
+ SupabaseModule
11
+ } from "./chunk-PA24P76K.js";
12
12
  import "./chunk-NAK4WDKS.js";
13
13
  import {
14
14
  REQUEST_ID_HEADER,
@@ -25,9 +25,6 @@ import {
25
25
  BaseCrudService,
26
26
  createCrudController
27
27
  } from "./chunk-JOVBJDJ2.js";
28
- import {
29
- BaseResponseDto
30
- } from "./chunk-XX2HPTRU.js";
31
28
  import {
32
29
  I18nModule,
33
30
  translateOr
@@ -49,6 +46,9 @@ import {
49
46
  import {
50
47
  AppException
51
48
  } from "./chunk-YFYHLYHN.js";
49
+ import {
50
+ BaseResponseDto
51
+ } from "./chunk-XX2HPTRU.js";
52
52
  import "./chunk-4MGIQFAJ.js";
53
53
  export {
54
54
  AppException,
@@ -20,28 +20,60 @@ var _common = require('@nestjs/common');
20
20
 
21
21
 
22
22
 
23
+ var _crypto = require('crypto');
24
+
23
25
  // src/media/media.service.ts
24
26
 
25
27
 
26
28
  // src/media/media.options.ts
27
29
  var MEDIA_OPTIONS = /* @__PURE__ */ Symbol("MEDIA_OPTIONS");
28
30
  var MEDIA_TTL = /* @__PURE__ */ Symbol("MEDIA_TTL");
31
+ var MEDIA_LIMITS = /* @__PURE__ */ Symbol("MEDIA_LIMITS");
29
32
 
30
33
  // src/media/media.service.ts
31
- var _crypto = require('crypto');
34
+
32
35
  var MediaService = class {
33
- constructor(ttlMs) {
36
+ constructor(ttlMs, limits) {
34
37
  this.ttlMs = ttlMs;
38
+ this.limits = limits;
35
39
  this.logger = new (0, _common.Logger)(MediaService.name);
36
40
  this.store = /* @__PURE__ */ new Map();
37
41
  }
42
+ /**
43
+ * Stores a file, or throws if the store is already full.
44
+ *
45
+ * The ceiling matters more than it looks. This map is the process's own
46
+ * heap, entries live for an hour, and nothing here bounded how many there
47
+ * could be: a loop against the upload endpoint — a bug in a publishing job
48
+ * as easily as someone with the secret — grows it until the process dies,
49
+ * and it takes the whole API down with it, not just uploads.
50
+ *
51
+ * Refusing is the right answer rather than evicting the oldest: an eviction
52
+ * would silently break a publication already in flight, and the caller
53
+ * cannot tell that its image vanished until the remote service reports a
54
+ * 404 it cannot explain.
55
+ */
38
56
  put(data, contentType) {
39
57
  this.sweep();
58
+ if (this.store.size >= this.limits.maxFiles) {
59
+ throw new (0, _common.PayloadTooLargeException)(
60
+ `The media store is full (${this.limits.maxFiles} files). Try again shortly.`
61
+ );
62
+ }
63
+ const total = this.bytesHeld();
64
+ if (total + data.length > this.limits.maxTotalBytes) {
65
+ throw new (0, _common.PayloadTooLargeException)("The media store is out of room. Try again shortly.");
66
+ }
40
67
  const id = _crypto.randomUUID.call(void 0, ).replace(/-/g, "");
41
68
  this.store.set(id, { data, contentType, expiresAt: Date.now() + this.ttlMs });
42
69
  this.logger.log(`Stored ${id} (${(data.length / 1024).toFixed(0)} KB)`);
43
70
  return id;
44
71
  }
72
+ bytesHeld() {
73
+ let total = 0;
74
+ for (const entry of this.store.values()) total += entry.data.length;
75
+ return total;
76
+ }
45
77
  get(id) {
46
78
  const found = this.store.get(id);
47
79
  if (!found) return void 0;
@@ -61,10 +93,18 @@ var MediaService = class {
61
93
  };
62
94
  MediaService = exports.MediaService = _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
63
95
  _common.Injectable.call(void 0, ),
64
- _chunk2REOCMUDcjs.__decorateParam.call(void 0, 0, _common.Inject.call(void 0, MEDIA_TTL))
96
+ _chunk2REOCMUDcjs.__decorateParam.call(void 0, 0, _common.Inject.call(void 0, MEDIA_TTL)),
97
+ _chunk2REOCMUDcjs.__decorateParam.call(void 0, 1, _common.Inject.call(void 0, MEDIA_LIMITS))
65
98
  ], MediaService);
66
99
 
67
100
  // src/media/media.controller.ts
101
+ function secretMatches(received, expected) {
102
+ if (!received) return false;
103
+ const a = Buffer.from(received);
104
+ const b = Buffer.from(expected);
105
+ return a.length === b.length && _crypto.timingSafeEqual.call(void 0, a, b);
106
+ }
107
+ var DEFAULT_ALLOWED = ["image/png", "image/jpeg", "image/webp", "image/gif"];
68
108
  var MediaController = class {
69
109
  // Both dependencies are injected by explicit token, like the rest of this
70
110
  // package. The bundler does not emit decorator metadata, so injecting by type
@@ -78,18 +118,25 @@ var MediaController = class {
78
118
  if (!this.options.uploadSecret) {
79
119
  throw new (0, _common.UnauthorizedException)("Media uploads are not configured.");
80
120
  }
81
- if (secret !== this.options.uploadSecret) {
121
+ if (!secretMatches(secret, this.options.uploadSecret)) {
82
122
  throw new (0, _common.UnauthorizedException)("Invalid secret.");
83
123
  }
84
124
  const data = request.body;
85
125
  if (!_optionalChain([data, 'optionalAccess', _ => _.length])) throw new (0, _common.UnauthorizedException)("Empty body.");
86
- const id = this.media.put(Buffer.from(data), _nullishCoalesce(request.headers["content-type"], () => ( "image/png")));
126
+ const allowed = _nullishCoalesce(this.options.allowedContentTypes, () => ( DEFAULT_ALLOWED));
127
+ const declared = _optionalChain([request, 'access', _2 => _2.headers, 'access', _3 => _3["content-type"], 'optionalAccess', _4 => _4.split, 'call', _5 => _5(";"), 'access', _6 => _6[0], 'optionalAccess', _7 => _7.trim, 'call', _8 => _8(), 'access', _9 => _9.toLowerCase, 'call', _10 => _10()]);
128
+ if (!declared || !allowed.includes(declared)) {
129
+ throw new (0, _common.BadRequestException)(
130
+ `Unsupported content type. Allowed: ${allowed.join(", ")}.`
131
+ );
132
+ }
133
+ const id = this.media.put(Buffer.from(data), declared);
87
134
  return { url: `${this.options.publicUrl}/media/${id}` };
88
135
  }
89
136
  serve(id, reply) {
90
137
  const found = this.media.get(id);
91
138
  if (!found) throw new (0, _common.NotFoundException)("That file is no longer available.");
92
- reply.header("content-type", found.contentType).header("cache-control", "no-store").send(found.data);
139
+ reply.header("content-type", found.contentType).header("cache-control", "no-store").header("x-content-type-options", "nosniff").send(found.data);
93
140
  }
94
141
  };
95
142
  _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
@@ -110,6 +157,7 @@ MediaController = _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
110
157
 
111
158
  // src/media/media.module.ts
112
159
  var ONE_HOUR = 60 * 60 * 1e3;
160
+ var DEFAULT_LIMITS = { maxFiles: 50, maxTotalBytes: 256 * 1024 * 1024 };
113
161
  var MediaModule = class {
114
162
  static forRoot(options) {
115
163
  return {
@@ -118,6 +166,7 @@ var MediaModule = class {
118
166
  providers: [
119
167
  { provide: MEDIA_OPTIONS, useValue: options },
120
168
  { provide: MEDIA_TTL, useValue: _nullishCoalesce(options.ttlMs, () => ( ONE_HOUR)) },
169
+ { provide: MEDIA_LIMITS, useValue: { ...DEFAULT_LIMITS, ...options.limits } },
121
170
  MediaService
122
171
  ],
123
172
  exports: [MediaService]
@@ -132,4 +181,5 @@ MediaModule = exports.MediaModule = _chunk2REOCMUDcjs.__decorateClass.call(void
132
181
 
133
182
 
134
183
 
135
- exports.MEDIA_OPTIONS = MEDIA_OPTIONS; exports.MEDIA_TTL = MEDIA_TTL; exports.MediaModule = MediaModule; exports.MediaService = MediaService;
184
+
185
+ exports.MEDIA_LIMITS = MEDIA_LIMITS; exports.MEDIA_OPTIONS = MEDIA_OPTIONS; exports.MEDIA_TTL = MEDIA_TTL; exports.MediaModule = MediaModule; exports.MediaService = MediaService;
@@ -1,5 +1,5 @@
1
1
  export { MediaModule } from './media.module.js';
2
2
  export { MediaService } from './media.service.js';
3
- export { MEDIA_OPTIONS, MEDIA_TTL } from './media.options.js';
4
- export type { MediaOptions } from './media.options.js';
3
+ export { MEDIA_OPTIONS, MEDIA_TTL, MEDIA_LIMITS } from './media.options.js';
4
+ export type { MediaOptions, MediaLimits } from './media.options.js';
5
5
  export type { StoredMedia } from './media.service.js';
@@ -8,6 +8,7 @@ import { Module } from "@nestjs/common";
8
8
 
9
9
  // src/media/media.controller.ts
10
10
  import {
11
+ BadRequestException,
11
12
  Controller,
12
13
  Get,
13
14
  Headers,
@@ -19,29 +20,60 @@ import {
19
20
  Res,
20
21
  UnauthorizedException
21
22
  } from "@nestjs/common";
23
+ import { timingSafeEqual } from "crypto";
22
24
 
23
25
  // src/media/media.service.ts
24
- import { Inject, Injectable, Logger } from "@nestjs/common";
26
+ import { Inject, Injectable, Logger, PayloadTooLargeException } from "@nestjs/common";
25
27
 
26
28
  // src/media/media.options.ts
27
29
  var MEDIA_OPTIONS = /* @__PURE__ */ Symbol("MEDIA_OPTIONS");
28
30
  var MEDIA_TTL = /* @__PURE__ */ Symbol("MEDIA_TTL");
31
+ var MEDIA_LIMITS = /* @__PURE__ */ Symbol("MEDIA_LIMITS");
29
32
 
30
33
  // src/media/media.service.ts
31
34
  import { randomUUID } from "crypto";
32
35
  var MediaService = class {
33
- constructor(ttlMs) {
36
+ constructor(ttlMs, limits) {
34
37
  this.ttlMs = ttlMs;
38
+ this.limits = limits;
35
39
  this.logger = new Logger(MediaService.name);
36
40
  this.store = /* @__PURE__ */ new Map();
37
41
  }
42
+ /**
43
+ * Stores a file, or throws if the store is already full.
44
+ *
45
+ * The ceiling matters more than it looks. This map is the process's own
46
+ * heap, entries live for an hour, and nothing here bounded how many there
47
+ * could be: a loop against the upload endpoint — a bug in a publishing job
48
+ * as easily as someone with the secret — grows it until the process dies,
49
+ * and it takes the whole API down with it, not just uploads.
50
+ *
51
+ * Refusing is the right answer rather than evicting the oldest: an eviction
52
+ * would silently break a publication already in flight, and the caller
53
+ * cannot tell that its image vanished until the remote service reports a
54
+ * 404 it cannot explain.
55
+ */
38
56
  put(data, contentType) {
39
57
  this.sweep();
58
+ if (this.store.size >= this.limits.maxFiles) {
59
+ throw new PayloadTooLargeException(
60
+ `The media store is full (${this.limits.maxFiles} files). Try again shortly.`
61
+ );
62
+ }
63
+ const total = this.bytesHeld();
64
+ if (total + data.length > this.limits.maxTotalBytes) {
65
+ throw new PayloadTooLargeException("The media store is out of room. Try again shortly.");
66
+ }
40
67
  const id = randomUUID().replace(/-/g, "");
41
68
  this.store.set(id, { data, contentType, expiresAt: Date.now() + this.ttlMs });
42
69
  this.logger.log(`Stored ${id} (${(data.length / 1024).toFixed(0)} KB)`);
43
70
  return id;
44
71
  }
72
+ bytesHeld() {
73
+ let total = 0;
74
+ for (const entry of this.store.values()) total += entry.data.length;
75
+ return total;
76
+ }
45
77
  get(id) {
46
78
  const found = this.store.get(id);
47
79
  if (!found) return void 0;
@@ -61,10 +93,18 @@ var MediaService = class {
61
93
  };
62
94
  MediaService = __decorateClass([
63
95
  Injectable(),
64
- __decorateParam(0, Inject(MEDIA_TTL))
96
+ __decorateParam(0, Inject(MEDIA_TTL)),
97
+ __decorateParam(1, Inject(MEDIA_LIMITS))
65
98
  ], MediaService);
66
99
 
67
100
  // src/media/media.controller.ts
101
+ function secretMatches(received, expected) {
102
+ if (!received) return false;
103
+ const a = Buffer.from(received);
104
+ const b = Buffer.from(expected);
105
+ return a.length === b.length && timingSafeEqual(a, b);
106
+ }
107
+ var DEFAULT_ALLOWED = ["image/png", "image/jpeg", "image/webp", "image/gif"];
68
108
  var MediaController = class {
69
109
  // Both dependencies are injected by explicit token, like the rest of this
70
110
  // package. The bundler does not emit decorator metadata, so injecting by type
@@ -78,18 +118,25 @@ var MediaController = class {
78
118
  if (!this.options.uploadSecret) {
79
119
  throw new UnauthorizedException("Media uploads are not configured.");
80
120
  }
81
- if (secret !== this.options.uploadSecret) {
121
+ if (!secretMatches(secret, this.options.uploadSecret)) {
82
122
  throw new UnauthorizedException("Invalid secret.");
83
123
  }
84
124
  const data = request.body;
85
125
  if (!data?.length) throw new UnauthorizedException("Empty body.");
86
- const id = this.media.put(Buffer.from(data), request.headers["content-type"] ?? "image/png");
126
+ const allowed = this.options.allowedContentTypes ?? DEFAULT_ALLOWED;
127
+ const declared = request.headers["content-type"]?.split(";")[0]?.trim().toLowerCase();
128
+ if (!declared || !allowed.includes(declared)) {
129
+ throw new BadRequestException(
130
+ `Unsupported content type. Allowed: ${allowed.join(", ")}.`
131
+ );
132
+ }
133
+ const id = this.media.put(Buffer.from(data), declared);
87
134
  return { url: `${this.options.publicUrl}/media/${id}` };
88
135
  }
89
136
  serve(id, reply) {
90
137
  const found = this.media.get(id);
91
138
  if (!found) throw new NotFoundException("That file is no longer available.");
92
- reply.header("content-type", found.contentType).header("cache-control", "no-store").send(found.data);
139
+ reply.header("content-type", found.contentType).header("cache-control", "no-store").header("x-content-type-options", "nosniff").send(found.data);
93
140
  }
94
141
  };
95
142
  __decorateClass([
@@ -110,6 +157,7 @@ MediaController = __decorateClass([
110
157
 
111
158
  // src/media/media.module.ts
112
159
  var ONE_HOUR = 60 * 60 * 1e3;
160
+ var DEFAULT_LIMITS = { maxFiles: 50, maxTotalBytes: 256 * 1024 * 1024 };
113
161
  var MediaModule = class {
114
162
  static forRoot(options) {
115
163
  return {
@@ -118,6 +166,7 @@ var MediaModule = class {
118
166
  providers: [
119
167
  { provide: MEDIA_OPTIONS, useValue: options },
120
168
  { provide: MEDIA_TTL, useValue: options.ttlMs ?? ONE_HOUR },
169
+ { provide: MEDIA_LIMITS, useValue: { ...DEFAULT_LIMITS, ...options.limits } },
121
170
  MediaService
122
171
  ],
123
172
  exports: [MediaService]
@@ -128,6 +177,7 @@ MediaModule = __decorateClass([
128
177
  Module({})
129
178
  ], MediaModule);
130
179
  export {
180
+ MEDIA_LIMITS,
131
181
  MEDIA_OPTIONS,
132
182
  MEDIA_TTL,
133
183
  MediaModule,
@@ -1,5 +1,11 @@
1
1
  export declare const MEDIA_OPTIONS: unique symbol;
2
2
  export declare const MEDIA_TTL: unique symbol;
3
+ export declare const MEDIA_LIMITS: unique symbol;
4
+ /** What the store refuses to exceed. See `MediaService` for why it has to. */
5
+ export interface MediaLimits {
6
+ maxFiles: number;
7
+ maxTotalBytes: number;
8
+ }
3
9
  export interface MediaOptions {
4
10
  /**
5
11
  * Base URL the generated links point to, **including the global prefix** if
@@ -9,6 +15,20 @@ export interface MediaOptions {
9
15
  publicUrl: string;
10
16
  /** Shared secret required to upload. Without it, uploads are refused. */
11
17
  uploadSecret?: string;
18
+ /**
19
+ * Content types accepted on upload and echoed back when serving.
20
+ *
21
+ * Defaults to PNG, JPEG, WebP and GIF — deliberately **not** SVG. An SVG is
22
+ * a document that can carry script, and serving one from your own domain
23
+ * runs that script in your application's origin: same cookies, same
24
+ * storage. Widen this only for formats that cannot execute.
25
+ */
26
+ allowedContentTypes?: string[];
27
+ /**
28
+ * Ceiling on what the store may hold at once. Defaults to 50 files and
29
+ * 256 MB. Uploads beyond it are refused rather than queued.
30
+ */
31
+ limits?: Partial<MediaLimits>;
12
32
  /** How long a file stays available. Defaults to one hour. */
13
33
  ttlMs?: number;
14
34
  }
@@ -1,3 +1,4 @@
1
+ import { type MediaLimits } from './media.options.js';
1
2
  export interface StoredMedia {
2
3
  data: Buffer;
3
4
  contentType: string;
@@ -18,10 +19,26 @@ export interface StoredMedia {
18
19
  */
19
20
  export declare class MediaService {
20
21
  private readonly ttlMs;
22
+ private readonly limits;
21
23
  private readonly logger;
22
24
  private readonly store;
23
- constructor(ttlMs: number);
25
+ constructor(ttlMs: number, limits: MediaLimits);
26
+ /**
27
+ * Stores a file, or throws if the store is already full.
28
+ *
29
+ * The ceiling matters more than it looks. This map is the process's own
30
+ * heap, entries live for an hour, and nothing here bounded how many there
31
+ * could be: a loop against the upload endpoint — a bug in a publishing job
32
+ * as easily as someone with the secret — grows it until the process dies,
33
+ * and it takes the whole API down with it, not just uploads.
34
+ *
35
+ * Refusing is the right answer rather than evicting the oldest: an eviction
36
+ * would silently break a publication already in flight, and the caller
37
+ * cannot tell that its image vanished until the remote service reports a
38
+ * 404 it cannot explain.
39
+ */
24
40
  put(data: Buffer, contentType: string): string;
41
+ private bytesHeld;
25
42
  get(id: string): StoredMedia | undefined;
26
43
  /** Swept on write, which is the only moment the map can have grown. */
27
44
  private sweep;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miguelmorales13/nestkit",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "type": "module",