@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 +50 -4
- package/dist/auth/oauth/index.cjs +75 -8
- package/dist/auth/oauth/index.js +73 -6
- package/dist/auth/oauth/oauth-auth.service.d.ts +29 -1
- package/dist/auth/oauth/oauth.options.d.ts +11 -0
- package/dist/index.cjs +5 -5
- package/dist/index.js +8 -8
- package/dist/media/index.cjs +57 -7
- package/dist/media/index.d.ts +2 -2
- package/dist/media/index.js +56 -6
- package/dist/media/media.options.d.ts +20 -0
- package/dist/media/media.service.d.ts +18 -1
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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',
|
|
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.
|
|
566
|
-
_chunk2REOCMUDcjs.__decorateParam.call(void 0, 3, _common.
|
|
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")))
|
package/dist/auth/oauth/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
566
|
-
__decorateParam(3,
|
|
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
|
-
|
|
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
|
|
6
|
+
var _chunkRF75KC63cjs = require('./chunk-RF75KC63.cjs');
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
var
|
|
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,
|
package/dist/media/index.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
184
|
+
|
|
185
|
+
exports.MEDIA_LIMITS = MEDIA_LIMITS; exports.MEDIA_OPTIONS = MEDIA_OPTIONS; exports.MEDIA_TTL = MEDIA_TTL; exports.MediaModule = MediaModule; exports.MediaService = MediaService;
|
package/dist/media/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/media/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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;
|