@getstrata/core 0.5.60 → 0.5.62

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -1
  3. package/dist/core/http/contentSecurityPolicy.d.ts +27 -0
  4. package/dist/core/http/requestMetaContext.d.ts +1 -0
  5. package/dist/core/http/safeInternalPath.d.ts +4 -0
  6. package/dist/core/http/securedRouteModelBinding.d.ts +2 -0
  7. package/dist/core/http/securityHeadersMiddleware.d.ts +5 -2
  8. package/dist/core/http/signedUrl.d.ts +11 -0
  9. package/dist/core/http/webErrorResponse.d.ts +1 -1
  10. package/dist/core/jobs/exportAuditLogsJob.d.ts +12 -0
  11. package/dist/core/mail/sanitizeMailHtml.d.ts +2 -0
  12. package/dist/core/security/totp.d.ts +7 -1
  13. package/dist/core/view/etaViewEngine.d.ts +3 -2
  14. package/dist/core/view/htmlResponse.d.ts +1 -2
  15. package/dist/core/view/index.d.ts +4 -1
  16. package/dist/core/view/viewEngine.d.ts +1 -0
  17. package/dist/core/view/webErrorView.d.ts +21 -0
  18. package/dist/entries/audit/exportAuditLogs.js +1 -1
  19. package/dist/entries/http/contentSecurityPolicy.js +1 -0
  20. package/dist/entries/http/requireAbilityMiddleware.js +44 -1
  21. package/dist/entries/http/requireWebAuthMiddleware.js +36 -3
  22. package/dist/entries/http/response.js +127 -555
  23. package/dist/entries/http/safeInternalPath.js +38 -0
  24. package/dist/entries/http/securedRouteModelBinding.js +27 -5
  25. package/dist/entries/http/securityHeadersMiddleware.js +34 -61
  26. package/dist/entries/http/signedUrl.js +97 -0
  27. package/dist/entries/http/webErrorResponse.js +126 -554
  28. package/dist/entries/jobs/dispatchWebhookJob.js +14 -12
  29. package/dist/entries/jobs/exportAuditLogsJob.js +340 -0
  30. package/dist/entries/logging/requestLoggingMiddleware.js +2 -1
  31. package/dist/entries/mail/markdownMail.js +62 -12
  32. package/dist/entries/mail/markdownMailable.js +62 -12
  33. package/dist/entries/mail/sanitizeMailHtml.js +60 -0
  34. package/dist/entries/openapi/generator.js +1 -1
  35. package/dist/entries/security/safeFetch.js +1 -1
  36. package/dist/entries/security/totp.js +31 -1
  37. package/dist/entries/view.js +88 -8
  38. package/dist/framework/public-api.d.ts +6 -1
  39. package/dist/index.js +744 -349
  40. package/package.json +27 -2
  41. package/dist/config/contentSecurityPolicy.d.ts +0 -5
@@ -0,0 +1,38 @@
1
+ // @bun
2
+ // ../../src/core/http/safeInternalPath.ts
3
+ function looksLikeExternalTarget(value) {
4
+ const trimmed = value.trim();
5
+ if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
6
+ return true;
7
+ }
8
+ if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
9
+ return true;
10
+ }
11
+ try {
12
+ const decoded = decodeURIComponent(trimmed);
13
+ if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
14
+ return true;
15
+ }
16
+ } catch {
17
+ return true;
18
+ }
19
+ return false;
20
+ }
21
+ function sanitizeInternalPath(raw, fallback = "/") {
22
+ if (looksLikeExternalTarget(raw)) {
23
+ return fallback;
24
+ }
25
+ return raw;
26
+ }
27
+ function safeInternalRedirectPath(request, fallback = "/") {
28
+ const url = new URL(request.url);
29
+ return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
30
+ }
31
+ function loginRedirectLocation(request) {
32
+ return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
33
+ }
34
+ export {
35
+ loginRedirectLocation,
36
+ safeInternalRedirectPath,
37
+ sanitizeInternalPath
38
+ };
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  // ../../src/core/http/securedRouteModelBinding.ts
3
3
  import { currentAuthUser as currentAuthUser2 } from "@getstrata/core/auth/authContext";
4
- import { BadRequestError as BadRequestError2 } from "@getstrata/core/errors/http";
4
+ import { BadRequestError as BadRequestError2, NotFoundError } from "@getstrata/core/errors/http";
5
5
  import {
6
6
  resolveApplicationAuth,
7
7
  resolveApplicationPolicyGate
@@ -238,10 +238,32 @@ function parsePositiveIntParam(value, name = "id") {
238
238
  function isMutatingPolicyAction(action) {
239
239
  return action === "update" || action === "delete";
240
240
  }
241
+ function modelHasIdentity(model) {
242
+ return !!model && typeof model === "object" && "id" in model && model.id !== undefined && model.id !== null;
243
+ }
244
+ function shouldApplyViewEtag(response, model, authorization) {
245
+ if (authorization.etag === false) {
246
+ return false;
247
+ }
248
+ if (authorization.etag === true) {
249
+ return true;
250
+ }
251
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
252
+ if (contentType.includes("text/html")) {
253
+ return false;
254
+ }
255
+ return modelHasIdentity(model);
256
+ }
257
+ function requireResolvedModel(model) {
258
+ if (model == null) {
259
+ throw new NotFoundError;
260
+ }
261
+ return model;
262
+ }
241
263
  function securedBindRouteModel(param, resolver, authorization, handler) {
242
264
  return async (request) => {
243
265
  const id = parsePositiveIntParam(String(request.params[param]), String(param));
244
- const model = await resolver(id, request);
266
+ const model = requireResolvedModel(await resolver(id, request));
245
267
  const gate = resolveApplicationPolicyGate();
246
268
  const auth = resolveApplicationAuth();
247
269
  const user = currentAuthUser2() ?? await auth.resolve(request);
@@ -252,7 +274,7 @@ function securedBindRouteModel(param, resolver, authorization, handler) {
252
274
  });
253
275
  }
254
276
  const response = await handler(request, model);
255
- if (isEtagEnabled() && authorization.action === "view") {
277
+ if (isEtagEnabled() && authorization.action === "view" && shouldApplyViewEtag(response, model, authorization)) {
256
278
  return applyConditionalGet(request, response, etagFromResource(model));
257
279
  }
258
280
  return response;
@@ -264,7 +286,7 @@ function securedBindRouteModelByKey(param, resolver, authorization, handler) {
264
286
  if (!key) {
265
287
  throw new BadRequestError2(`Missing route parameter "${String(param)}".`);
266
288
  }
267
- const model = await resolver(key, request);
289
+ const model = requireResolvedModel(await resolver(key, request));
268
290
  const gate = resolveApplicationPolicyGate();
269
291
  const auth = resolveApplicationAuth();
270
292
  const user = currentAuthUser2() ?? await auth.resolve(request);
@@ -275,7 +297,7 @@ function securedBindRouteModelByKey(param, resolver, authorization, handler) {
275
297
  });
276
298
  }
277
299
  const response = await handler(request, model);
278
- if (isEtagEnabled() && authorization.action === "view") {
300
+ if (isEtagEnabled() && authorization.action === "view" && shouldApplyViewEtag(response, model, authorization)) {
279
301
  return applyConditionalGet(request, response, etagFromResource(model));
280
302
  }
281
303
  return response;
@@ -1,77 +1,50 @@
1
1
  // @bun
2
+ // ../../src/core/http/securityHeadersMiddleware.ts
3
+ import {
4
+ configureContentSecurityPolicy,
5
+ generateCspNonce,
6
+ resolveContentSecurityPolicy
7
+ } from "@getstrata/core/http/contentSecurityPolicy";
8
+ import { currentRequestMeta, runWithRequestMeta } from "@getstrata/core/http/requestMetaContext";
9
+
2
10
  // ../../src/config/app.ts
3
11
  var appConfig = {
4
- name: "WorkHub",
12
+ name: process.env.APP_NAME?.trim() || "WorkHub",
5
13
  env: process.env.APP_ENV ?? "local",
6
14
  debug: (process.env.APP_DEBUG ?? "true") !== "false",
7
15
  url: process.env.APP_URL ?? "http://localhost:3000",
8
16
  apiPrefix: process.env.API_PREFIX ?? "/api/v1"
9
17
  };
10
18
 
11
- // ../../src/config/contentSecurityPolicy.ts
12
- function strictApiContentSecurityPolicy() {
13
- return "default-src 'none'; frame-ancestors 'none'; base-uri 'none'";
14
- }
15
- function serverHtmxContentSecurityPolicy() {
16
- return [
17
- "default-src 'self'",
18
- "script-src 'self' https://unpkg.com",
19
- "style-src 'self'",
20
- "connect-src 'self'",
21
- "img-src 'self'",
22
- "font-src 'self'",
23
- "form-action 'self'",
24
- "frame-ancestors 'none'",
25
- "base-uri 'self'"
26
- ].join("; ");
27
- }
28
- function spaContentSecurityPolicy() {
29
- return [
30
- "default-src 'self'",
31
- "script-src 'self'",
32
- "style-src 'self'",
33
- "connect-src 'self'",
34
- "img-src 'self'",
35
- "font-src 'self'",
36
- "frame-ancestors 'none'",
37
- "base-uri 'self'"
38
- ].join("; ");
39
- }
40
- function resolveContentSecurityPolicy(response) {
41
- const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
42
- if (!contentType.includes("text/html")) {
43
- return strictApiContentSecurityPolicy();
44
- }
45
- const frontendMode = (process.env.FRONTEND_MODE ?? "api").trim();
46
- if (frontendMode === "server-htmx") {
47
- return serverHtmxContentSecurityPolicy();
48
- }
49
- if (frontendMode === "spa-react") {
50
- return spaContentSecurityPolicy();
51
- }
52
- return strictApiContentSecurityPolicy();
53
- }
54
-
55
19
  // ../../src/core/http/securityHeadersMiddleware.ts
56
- function createSecurityHeadersMiddleware() {
57
- return async (_request, next) => {
58
- const response = await next();
59
- const headers = new Headers(response.headers);
60
- headers.set("X-Content-Type-Options", "nosniff");
61
- headers.set("X-Frame-Options", "DENY");
62
- headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
63
- headers.set("X-XSS-Protection", "0");
64
- headers.set("Content-Security-Policy", resolveContentSecurityPolicy(response));
65
- if (appConfig.env === "production") {
66
- headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
67
- }
68
- return new Response(response.body, {
69
- status: response.status,
70
- statusText: response.statusText,
71
- headers
20
+ function createSecurityHeadersMiddleware(options = {}) {
21
+ return async (request, next) => {
22
+ const nonce = generateCspNonce();
23
+ const existing = currentRequestMeta();
24
+ return await runWithRequestMeta({
25
+ ...existing,
26
+ request: existing.request ?? request,
27
+ cspNonce: nonce
28
+ }, async () => {
29
+ const response = await next();
30
+ const headers = new Headers(response.headers);
31
+ headers.set("X-Content-Type-Options", "nosniff");
32
+ headers.set("X-Frame-Options", "DENY");
33
+ headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
34
+ headers.set("X-XSS-Protection", "0");
35
+ headers.set("Content-Security-Policy", resolveContentSecurityPolicy(response, { ...options, nonce }));
36
+ if (appConfig.env === "production") {
37
+ headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
38
+ }
39
+ return new Response(response.body, {
40
+ status: response.status,
41
+ statusText: response.statusText,
42
+ headers
43
+ });
72
44
  });
73
45
  };
74
46
  }
75
47
  export {
48
+ configureContentSecurityPolicy,
76
49
  createSecurityHeadersMiddleware
77
50
  };
@@ -0,0 +1,97 @@
1
+ // @bun
2
+ // ../../src/core/http/signedUrl.ts
3
+ import { createHmac } from "crypto";
4
+ import { ForbiddenError } from "@getstrata/core/errors/http";
5
+ import { timingSafeCompareString } from "@getstrata/core/security/timingSafeCompare";
6
+ function resolveSignedUrlSecret() {
7
+ return process.env.SIGNED_URL_SECRET?.trim() || process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || "workhub-dev-signed-url-secret";
8
+ }
9
+ function resolveSignedUrlOrigin() {
10
+ return (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
11
+ }
12
+ function normalizeSignedPath(path) {
13
+ const trimmed = path.trim();
14
+ if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
15
+ throw new Error("Signed URLs must use a same-origin absolute path.");
16
+ }
17
+ if (trimmed.includes("://")) {
18
+ throw new Error("Signed URLs must use a same-origin absolute path.");
19
+ }
20
+ return trimmed;
21
+ }
22
+ function sortedQueryString(params) {
23
+ const entries = [...params.entries()].filter(([key]) => key !== "signature").sort(([left], [right]) => left.localeCompare(right));
24
+ return new URLSearchParams(entries).toString();
25
+ }
26
+ function signCanonicalPayload(path, query) {
27
+ return createHmac("sha256", resolveSignedUrlSecret()).update(`${path}
28
+ ${query}`).digest("hex");
29
+ }
30
+ function buildSignedSearchParams(path, query = {}, expiresAt) {
31
+ const params = new URLSearchParams;
32
+ for (const [key, value] of Object.entries(query)) {
33
+ if (key === "signature" || key === "expires") {
34
+ continue;
35
+ }
36
+ params.set(key, String(value));
37
+ }
38
+ if (expiresAt !== undefined) {
39
+ params.set("expires", String(expiresAt));
40
+ }
41
+ params.set("signature", signCanonicalPayload(path, sortedQueryString(params)));
42
+ return params;
43
+ }
44
+ function signedUrl(path, query = {}) {
45
+ const normalizedPath = normalizeSignedPath(path);
46
+ const params = buildSignedSearchParams(normalizedPath, query);
47
+ return `${normalizedPath}?${params.toString()}`;
48
+ }
49
+ function temporarySignedUrl(path, expiresInSeconds, query = {}) {
50
+ if (!Number.isInteger(expiresInSeconds) || expiresInSeconds <= 0) {
51
+ throw new Error("Signed URL expiry must be a positive integer number of seconds.");
52
+ }
53
+ const normalizedPath = normalizeSignedPath(path);
54
+ const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
55
+ const params = buildSignedSearchParams(normalizedPath, query, expiresAt);
56
+ return `${normalizedPath}?${params.toString()}`;
57
+ }
58
+ function absoluteTemporarySignedUrl(path, expiresInSeconds, query = {}, origin = resolveSignedUrlOrigin()) {
59
+ return `${origin.replace(/\/$/, "")}${temporarySignedUrl(path, expiresInSeconds, query)}`;
60
+ }
61
+ function readSignedRequestUrl(input) {
62
+ if (input instanceof URL) {
63
+ return input;
64
+ }
65
+ if (typeof input === "string") {
66
+ return new URL(input, resolveSignedUrlOrigin());
67
+ }
68
+ return new URL(input.url);
69
+ }
70
+ function hasValidSignature(input) {
71
+ const url = readSignedRequestUrl(input);
72
+ const signature = url.searchParams.get("signature");
73
+ if (!signature) {
74
+ return false;
75
+ }
76
+ const expires = url.searchParams.get("expires");
77
+ if (expires) {
78
+ const expiresAt = Number.parseInt(expires, 10);
79
+ if (!Number.isInteger(expiresAt) || expiresAt <= Math.floor(Date.now() / 1000)) {
80
+ return false;
81
+ }
82
+ }
83
+ const expected = signCanonicalPayload(url.pathname, sortedQueryString(url.searchParams));
84
+ return timingSafeCompareString(signature, expected);
85
+ }
86
+ function assertValidSignature(input) {
87
+ if (!hasValidSignature(input)) {
88
+ throw new ForbiddenError("Invalid or expired signed URL.");
89
+ }
90
+ }
91
+ export {
92
+ absoluteTemporarySignedUrl,
93
+ assertValidSignature,
94
+ hasValidSignature,
95
+ signedUrl,
96
+ temporarySignedUrl
97
+ };