@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
@@ -3,15 +3,6 @@
3
3
  import { createHmac } from "crypto";
4
4
  import { repositoryConnection as db2 } from "@getstrata/core/database/repositoryConnection";
5
5
 
6
- // ../../src/config/app.ts
7
- var appConfig = {
8
- name: "WorkHub",
9
- env: process.env.APP_ENV ?? "local",
10
- debug: (process.env.APP_DEBUG ?? "true") !== "false",
11
- url: process.env.APP_URL ?? "http://localhost:3000",
12
- apiPrefix: process.env.API_PREFIX ?? "/api/v1"
13
- };
14
-
15
6
  // ../../src/core/queue/index.ts
16
7
  class Job {
17
8
  maxAttempts;
@@ -38,6 +29,15 @@ function createQueue(driver) {
38
29
  return driver === "async" ? new AsyncQueue : new SyncQueue;
39
30
  }
40
31
 
32
+ // ../../src/config/app.ts
33
+ var appConfig = {
34
+ name: process.env.APP_NAME?.trim() || "WorkHub",
35
+ env: process.env.APP_ENV ?? "local",
36
+ debug: (process.env.APP_DEBUG ?? "true") !== "false",
37
+ url: process.env.APP_URL ?? "http://localhost:3000",
38
+ apiPrefix: process.env.API_PREFIX ?? "/api/v1"
39
+ };
40
+
41
41
  // ../../src/core/security/safeUrl.ts
42
42
  import { lookup as dnsLookupImpl } from "dns/promises";
43
43
  import { BadRequestError } from "@getstrata/core/errors/http";
@@ -292,7 +292,9 @@ class DispatchWebhookJob extends Job {
292
292
  }
293
293
  const body = JSON.stringify({ event: payload.event, payload: payload.payload });
294
294
  const signature = createHmac("sha256", webhook.secret).update(body).digest("hex");
295
- assertSafeOutboundUrl(webhook.url, { allowHttp: appConfig.env !== "production" });
295
+ const allowHttp = (process.env.APP_ENV ?? "local") !== "production";
296
+ const signatureHeader = process.env.WEBHOOK_SIGNATURE_HEADER?.trim() || "x-workhub-signature";
297
+ assertSafeOutboundUrl(webhook.url, { allowHttp });
296
298
  let responseStatus = null;
297
299
  let errorMessage = null;
298
300
  try {
@@ -300,10 +302,10 @@ class DispatchWebhookJob extends Job {
300
302
  method: "POST",
301
303
  headers: {
302
304
  "content-type": "application/json",
303
- "x-workhub-signature": signature
305
+ [signatureHeader]: signature
304
306
  },
305
307
  body
306
- }, { allowHttp: appConfig.env !== "production" });
308
+ }, { allowHttp });
307
309
  responseStatus = response.status;
308
310
  if (!response.ok) {
309
311
  throw new Error(`Webhook delivery failed with status ${response.status}.`);
@@ -0,0 +1,340 @@
1
+ // @bun
2
+ // ../../src/core/audit/exportAuditLogs.ts
3
+ import { repositoryConnection as db2 } from "@getstrata/core/database/repositoryConnection";
4
+
5
+ // ../../src/config/app.ts
6
+ var appConfig = {
7
+ name: process.env.APP_NAME?.trim() || "WorkHub",
8
+ env: process.env.APP_ENV ?? "local",
9
+ debug: (process.env.APP_DEBUG ?? "true") !== "false",
10
+ url: process.env.APP_URL ?? "http://localhost:3000",
11
+ apiPrefix: process.env.API_PREFIX ?? "/api/v1"
12
+ };
13
+
14
+ // ../../src/core/security/safeUrl.ts
15
+ import { lookup as dnsLookupImpl } from "dns/promises";
16
+ import { BadRequestError } from "@getstrata/core/errors/http";
17
+ var dnsLookup = dnsLookupImpl;
18
+ var BLOCKED_HOSTNAMES = new Set([
19
+ "localhost",
20
+ "127.0.0.1",
21
+ "0.0.0.0",
22
+ "::1",
23
+ "metadata.google.internal"
24
+ ]);
25
+ function isPrivateIpv4(hostname) {
26
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
27
+ if (!match) {
28
+ return false;
29
+ }
30
+ const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
31
+ if (octets.some((octet) => octet < 0 || octet > 255)) {
32
+ return true;
33
+ }
34
+ const [a = 0, b = 0] = octets;
35
+ if (a === 10) {
36
+ return true;
37
+ }
38
+ if (a === 127) {
39
+ return true;
40
+ }
41
+ if (a === 0) {
42
+ return true;
43
+ }
44
+ if (a === 169 && b === 254) {
45
+ return true;
46
+ }
47
+ if (a === 172 && b >= 16 && b <= 31) {
48
+ return true;
49
+ }
50
+ if (a === 192 && b === 168) {
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+ function isBlockedHostname(hostname) {
56
+ const normalized = hostname.trim().toLowerCase();
57
+ if (normalized.length === 0) {
58
+ return true;
59
+ }
60
+ if (BLOCKED_HOSTNAMES.has(normalized)) {
61
+ return true;
62
+ }
63
+ if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
64
+ return true;
65
+ }
66
+ if (normalized.includes(":")) {
67
+ return true;
68
+ }
69
+ return isPrivateIpv4(normalized);
70
+ }
71
+ function assertSafeOutboundUrl(rawUrl, options = {}) {
72
+ let parsed;
73
+ try {
74
+ parsed = new URL(rawUrl);
75
+ } catch {
76
+ throw new BadRequestError("Webhook URL is invalid.");
77
+ }
78
+ if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
79
+ throw new BadRequestError("Webhook URL must use HTTPS.");
80
+ }
81
+ if (parsed.username || parsed.password) {
82
+ throw new BadRequestError("Webhook URL must not include credentials.");
83
+ }
84
+ if (isBlockedHostname(parsed.hostname)) {
85
+ throw new BadRequestError("Webhook URL targets a blocked host.");
86
+ }
87
+ return parsed;
88
+ }
89
+ function isBlockedIpAddress(address) {
90
+ return isBlockedHostname(address.trim().toLowerCase());
91
+ }
92
+ async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
93
+ const parsed = assertSafeOutboundUrl(rawUrl, options);
94
+ if (options.resolveDns === false) {
95
+ return parsed;
96
+ }
97
+ const hostname = parsed.hostname.trim().toLowerCase();
98
+ const results = await dnsLookup(hostname, { all: true, verbatim: true });
99
+ if (results.some((result) => isBlockedIpAddress(result.address))) {
100
+ throw new BadRequestError("Webhook URL targets a blocked host.");
101
+ }
102
+ return parsed;
103
+ }
104
+ function setDnsLookupForTests(lookupFn) {
105
+ dnsLookup = lookupFn;
106
+ }
107
+ function resetDnsLookupForTests() {
108
+ dnsLookup = dnsLookupImpl;
109
+ }
110
+
111
+ // ../../src/core/security/safeFetch.ts
112
+ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
113
+ async function safeFetch(input, init = {}, options = {}) {
114
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
115
+ const maxRedirects = options.maxRedirects ?? 0;
116
+ const resolveDns = options.resolveDns ?? appConfig.env === "production";
117
+ const urlOptions = { allowHttp: options.allowHttp, resolveDns };
118
+ const controller = new AbortController;
119
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
120
+ try {
121
+ let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
122
+ let redirectCount = 0;
123
+ while (true) {
124
+ const response = await fetch(currentUrl, {
125
+ ...init,
126
+ signal: controller.signal,
127
+ redirect: "manual"
128
+ });
129
+ if (response.status >= 300 && response.status < 400) {
130
+ const location = response.headers.get("location");
131
+ if (!location || redirectCount >= maxRedirects) {
132
+ return response;
133
+ }
134
+ currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
135
+ redirectCount += 1;
136
+ continue;
137
+ }
138
+ return response;
139
+ }
140
+ } finally {
141
+ clearTimeout(timeout);
142
+ }
143
+ }
144
+
145
+ // ../../src/core/tenant/databaseTenantContext.ts
146
+ import { repositoryConnection as db } from "@getstrata/core/database/repositoryConnection";
147
+ async function runWithMigrationBypass(callback) {
148
+ await db`SELECT set_config('app.bypass_rls', 'true', false)`;
149
+ try {
150
+ return await callback();
151
+ } finally {
152
+ await db`SELECT set_config('app.bypass_rls', 'false', false)`;
153
+ }
154
+ }
155
+
156
+ // ../../src/core/audit/siemFormatter.ts
157
+ function formatSiemAuditEvent(input) {
158
+ return {
159
+ timestamp: input.created_at.toISOString(),
160
+ event_type: "workhub.audit",
161
+ actor_user_id: input.user_id,
162
+ tenant_id: input.tenant_id ?? null,
163
+ trace_id: input.trace_id ?? null,
164
+ action: input.action,
165
+ subject_type: input.subject_type,
166
+ subject_id: input.subject_id,
167
+ ip_address: input.ip_address ?? null,
168
+ user_agent: input.user_agent ?? null,
169
+ checksum: input.checksum ?? null,
170
+ payload: input.payload ?? {}
171
+ };
172
+ }
173
+ function formatCefLine(event) {
174
+ const extension = [
175
+ `rt=${event.timestamp}`,
176
+ `suid=${event.actor_user_id ?? "unknown"}`,
177
+ `cs1=${event.action}`,
178
+ `cs1Label=Action`,
179
+ `cs2=${event.subject_type}`,
180
+ `cs2Label=SubjectType`,
181
+ `cs3=${event.subject_id ?? ""}`,
182
+ `cs3Label=SubjectId`,
183
+ `src=${event.ip_address ?? ""}`,
184
+ `request=${event.trace_id ?? ""}`
185
+ ].join(" ");
186
+ return `CEF:0|WorkHub|API|1.0|${event.action}|${event.subject_type}|5|${extension}`;
187
+ }
188
+
189
+ // ../../src/core/audit/exportAuditLogs.ts
190
+ function resolveAuditExportConfig() {
191
+ const endpoint = process.env.SIEM_EXPORT_URL?.trim();
192
+ if (!endpoint) {
193
+ return null;
194
+ }
195
+ assertSafeOutboundUrl(endpoint, { allowHttp: appConfig.env !== "production" });
196
+ const batchSize = Number(process.env.SIEM_EXPORT_BATCH_SIZE ?? "100");
197
+ return {
198
+ endpoint,
199
+ format: process.env.SIEM_EXPORT_FORMAT === "cef" ? "cef" : "json",
200
+ batchSize: Number.isFinite(batchSize) ? batchSize : 100
201
+ };
202
+ }
203
+ async function exportPendingAuditLogs() {
204
+ const config = resolveAuditExportConfig();
205
+ if (!config) {
206
+ return 0;
207
+ }
208
+ return await runWithMigrationBypass(async () => {
209
+ const rows = await db2`
210
+ SELECT
211
+ id,
212
+ user_id,
213
+ action,
214
+ subject_type,
215
+ subject_id,
216
+ payload,
217
+ ip_address,
218
+ user_agent,
219
+ checksum,
220
+ tenant_id,
221
+ trace_id,
222
+ created_at
223
+ FROM audit_log
224
+ WHERE exported_at IS NULL
225
+ ORDER BY id
226
+ LIMIT ${config.batchSize}
227
+ `;
228
+ if (rows.length === 0) {
229
+ return 0;
230
+ }
231
+ const events = rows.map((row) => formatSiemAuditEvent({
232
+ action: row.action,
233
+ subject_type: row.subject_type,
234
+ subject_id: row.subject_id,
235
+ user_id: row.user_id,
236
+ tenant_id: row.tenant_id,
237
+ trace_id: row.trace_id,
238
+ ip_address: row.ip_address,
239
+ user_agent: row.user_agent,
240
+ checksum: row.checksum,
241
+ payload: row.payload,
242
+ created_at: row.created_at
243
+ }));
244
+ const body = config.format === "cef" ? events.map((event) => formatCefLine(event)).join(`
245
+ `) : JSON.stringify({ events });
246
+ const response = await safeFetch(config.endpoint, {
247
+ method: "POST",
248
+ headers: {
249
+ "content-type": config.format === "cef" ? "text/plain" : "application/json",
250
+ ...process.env.SIEM_EXPORT_TOKEN ? { authorization: `Bearer ${process.env.SIEM_EXPORT_TOKEN}` } : {}
251
+ },
252
+ body
253
+ }, { allowHttp: appConfig.env !== "production" });
254
+ if (!response.ok) {
255
+ throw new Error(`SIEM export failed with status ${response.status}.`);
256
+ }
257
+ const ids = rows.map((row) => row.id);
258
+ for (const id of ids) {
259
+ await db2`UPDATE audit_log SET exported_at = NOW() WHERE id = ${id}`;
260
+ }
261
+ return rows.length;
262
+ });
263
+ }
264
+
265
+ // ../../src/core/logging/logger.ts
266
+ class Logger {
267
+ channel;
268
+ constructor(channel = "app") {
269
+ this.channel = channel;
270
+ }
271
+ write(level, message, context = {}) {
272
+ const entry = {
273
+ level,
274
+ channel: this.channel,
275
+ message,
276
+ timestamp: new Date().toISOString(),
277
+ ...context
278
+ };
279
+ const line = JSON.stringify(entry);
280
+ if (level === "error") {
281
+ console.error(line);
282
+ return;
283
+ }
284
+ console.log(line);
285
+ }
286
+ debug(message, context) {
287
+ this.write("debug", message, context);
288
+ }
289
+ info(message, context) {
290
+ this.write("info", message, context);
291
+ }
292
+ warn(message, context) {
293
+ this.write("warn", message, context);
294
+ }
295
+ error(message, context) {
296
+ this.write("error", message, context);
297
+ }
298
+ }
299
+ var appLogger = new Logger("app");
300
+
301
+ // ../../src/core/queue/index.ts
302
+ class Job {
303
+ maxAttempts;
304
+ backoffMs;
305
+ priority;
306
+ }
307
+
308
+ class SyncQueue {
309
+ async dispatch(job, payload) {
310
+ await job.handle(payload);
311
+ }
312
+ }
313
+
314
+ class AsyncQueue {
315
+ async dispatch(job, payload) {
316
+ setTimeout(() => {
317
+ job.handle(payload).catch((error) => {
318
+ console.error("[AsyncQueue] Job failed:", error);
319
+ });
320
+ }, 0);
321
+ }
322
+ }
323
+ function createQueue(driver) {
324
+ return driver === "async" ? new AsyncQueue : new SyncQueue;
325
+ }
326
+
327
+ // ../../src/core/jobs/exportAuditLogsJob.ts
328
+ class ExportAuditLogsJob extends Job {
329
+ maxAttempts = 2;
330
+ backoffMs = 5000;
331
+ async handle(_payload = {}) {
332
+ const exported = await exportPendingAuditLogs();
333
+ if (exported > 0) {
334
+ appLogger.info(`Exported ${exported} audit log entries to SIEM.`);
335
+ }
336
+ }
337
+ }
338
+ export {
339
+ ExportAuditLogsJob
340
+ };
@@ -1,6 +1,6 @@
1
1
  // @bun
2
2
  // ../../src/core/logging/requestLoggingMiddleware.ts
3
- import { runWithRequestMeta } from "@getstrata/core/http/requestMetaContext";
3
+ import { currentRequestMeta, runWithRequestMeta } from "@getstrata/core/http/requestMetaContext";
4
4
 
5
5
  // ../../src/core/http/clientIp.ts
6
6
  function trustForwardedFor(env = process.env) {
@@ -57,6 +57,7 @@ var appLogger = new Logger("app");
57
57
  function createRequestLoggingMiddleware() {
58
58
  return async (request, next) => {
59
59
  return await runWithRequestMeta({
60
+ ...currentRequestMeta(),
60
61
  ipAddress: readClientIp(request) ?? null,
61
62
  userAgent: request.headers.get("user-agent"),
62
63
  request
@@ -1,27 +1,77 @@
1
1
  // @bun
2
- // ../../src/core/mail/markdownMail.ts
2
+ // ../../src/core/mail/sanitizeMailHtml.ts
3
+ var ALLOWED_TAGS = new Set([
4
+ "a",
5
+ "blockquote",
6
+ "br",
7
+ "code",
8
+ "em",
9
+ "h1",
10
+ "h2",
11
+ "h3",
12
+ "h4",
13
+ "h5",
14
+ "h6",
15
+ "hr",
16
+ "li",
17
+ "ol",
18
+ "p",
19
+ "pre",
20
+ "strong",
21
+ "ul"
22
+ ]);
3
23
  function escapeHtml(value) {
4
24
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5
25
  }
26
+ function decodeBasicEntities(value) {
27
+ return value.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
28
+ }
29
+ function isSafeHref(value) {
30
+ return /^(https?:|mailto:)/i.test(value.trim());
31
+ }
32
+ function sanitizeAttributes(tagName, rawAttributes) {
33
+ if (tagName !== "a") {
34
+ return "";
35
+ }
36
+ const hrefMatch = rawAttributes.match(/\bhref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
37
+ const href = decodeBasicEntities(hrefMatch?.[2] ?? hrefMatch?.[3] ?? hrefMatch?.[4] ?? "");
38
+ if (!href || !isSafeHref(href)) {
39
+ return "";
40
+ }
41
+ return ` href="${escapeHtml(href)}"`;
42
+ }
43
+ function sanitizeMailHtml(html) {
44
+ return html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (match, tagName, rawAttributes) => {
45
+ const name = tagName.toLowerCase();
46
+ if (!ALLOWED_TAGS.has(name)) {
47
+ return "";
48
+ }
49
+ if (match.startsWith("</")) {
50
+ return `</${name}>`;
51
+ }
52
+ if (match.endsWith("/>") || name === "br" || name === "hr") {
53
+ return `<${name}>`;
54
+ }
55
+ return `<${name}${sanitizeAttributes(name, rawAttributes)}>`;
56
+ });
57
+ }
58
+
59
+ // ../../src/core/mail/markdownMail.ts
60
+ function escapeHtml2(value) {
61
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
62
+ }
6
63
  function stripMarkdown(markdown) {
7
64
  return markdown.replace(/^#{1,6}\s+/gm, "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)").replace(/^[-*]\s+/gm, "\u2022 ").replace(/```[\s\S]*?```/g, "").replace(/\n{3,}/g, `
8
65
 
9
66
  `).trim();
10
67
  }
11
68
  function markdownToHtml(markdown) {
12
- const escaped = markdown.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13
- return escaped.replace(/^### (.+)$/gm, "<h3>$1</h3>").replace(/^## (.+)$/gm, "<h2>$1</h2>").replace(/^# (.+)$/gm, "<h1>$1</h1>").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>').replace(/^[-*]\s+(.+)$/gm, "<li>$1</li>").replace(/(<li>[\s\S]*?<\/li>\n?)+/g, (block) => `<ul>${block}</ul>`).replace(/```(\w+)?\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>").split(/\n\n+/).map((block) => {
14
- if (block.startsWith("<")) {
15
- return block;
16
- }
17
- return `<p>${block.replace(/\n/g, " ")}</p>`;
18
- }).join(`
19
- `);
69
+ return sanitizeMailHtml(Bun.markdown.html(markdown));
20
70
  }
21
71
  function wrapMarkdownMailLayout(bodyHtml, options = {}) {
22
- const title = escapeHtml(options.title ?? "GetStrata");
23
- const preview = escapeHtml(options.preview ?? "");
24
- const footer = escapeHtml(options.footer ?? "Sent by GetStrata");
72
+ const title = escapeHtml2(options.title ?? "GetStrata");
73
+ const preview = escapeHtml2(options.preview ?? "");
74
+ const footer = escapeHtml2(options.footer ?? "Sent by GetStrata");
25
75
  return `<!DOCTYPE html>
26
76
  <html lang="en">
27
77
  <head>
@@ -1,27 +1,77 @@
1
1
  // @bun
2
- // ../../src/core/mail/markdownMail.ts
2
+ // ../../src/core/mail/sanitizeMailHtml.ts
3
+ var ALLOWED_TAGS = new Set([
4
+ "a",
5
+ "blockquote",
6
+ "br",
7
+ "code",
8
+ "em",
9
+ "h1",
10
+ "h2",
11
+ "h3",
12
+ "h4",
13
+ "h5",
14
+ "h6",
15
+ "hr",
16
+ "li",
17
+ "ol",
18
+ "p",
19
+ "pre",
20
+ "strong",
21
+ "ul"
22
+ ]);
3
23
  function escapeHtml(value) {
4
24
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5
25
  }
26
+ function decodeBasicEntities(value) {
27
+ return value.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
28
+ }
29
+ function isSafeHref(value) {
30
+ return /^(https?:|mailto:)/i.test(value.trim());
31
+ }
32
+ function sanitizeAttributes(tagName, rawAttributes) {
33
+ if (tagName !== "a") {
34
+ return "";
35
+ }
36
+ const hrefMatch = rawAttributes.match(/\bhref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
37
+ const href = decodeBasicEntities(hrefMatch?.[2] ?? hrefMatch?.[3] ?? hrefMatch?.[4] ?? "");
38
+ if (!href || !isSafeHref(href)) {
39
+ return "";
40
+ }
41
+ return ` href="${escapeHtml(href)}"`;
42
+ }
43
+ function sanitizeMailHtml(html) {
44
+ return html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (match, tagName, rawAttributes) => {
45
+ const name = tagName.toLowerCase();
46
+ if (!ALLOWED_TAGS.has(name)) {
47
+ return "";
48
+ }
49
+ if (match.startsWith("</")) {
50
+ return `</${name}>`;
51
+ }
52
+ if (match.endsWith("/>") || name === "br" || name === "hr") {
53
+ return `<${name}>`;
54
+ }
55
+ return `<${name}${sanitizeAttributes(name, rawAttributes)}>`;
56
+ });
57
+ }
58
+
59
+ // ../../src/core/mail/markdownMail.ts
60
+ function escapeHtml2(value) {
61
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
62
+ }
6
63
  function stripMarkdown(markdown) {
7
64
  return markdown.replace(/^#{1,6}\s+/gm, "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)").replace(/^[-*]\s+/gm, "\u2022 ").replace(/```[\s\S]*?```/g, "").replace(/\n{3,}/g, `
8
65
 
9
66
  `).trim();
10
67
  }
11
68
  function markdownToHtml(markdown) {
12
- const escaped = markdown.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13
- return escaped.replace(/^### (.+)$/gm, "<h3>$1</h3>").replace(/^## (.+)$/gm, "<h2>$1</h2>").replace(/^# (.+)$/gm, "<h1>$1</h1>").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>').replace(/^[-*]\s+(.+)$/gm, "<li>$1</li>").replace(/(<li>[\s\S]*?<\/li>\n?)+/g, (block) => `<ul>${block}</ul>`).replace(/```(\w+)?\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>").split(/\n\n+/).map((block) => {
14
- if (block.startsWith("<")) {
15
- return block;
16
- }
17
- return `<p>${block.replace(/\n/g, " ")}</p>`;
18
- }).join(`
19
- `);
69
+ return sanitizeMailHtml(Bun.markdown.html(markdown));
20
70
  }
21
71
  function wrapMarkdownMailLayout(bodyHtml, options = {}) {
22
- const title = escapeHtml(options.title ?? "GetStrata");
23
- const preview = escapeHtml(options.preview ?? "");
24
- const footer = escapeHtml(options.footer ?? "Sent by GetStrata");
72
+ const title = escapeHtml2(options.title ?? "GetStrata");
73
+ const preview = escapeHtml2(options.preview ?? "");
74
+ const footer = escapeHtml2(options.footer ?? "Sent by GetStrata");
25
75
  return `<!DOCTYPE html>
26
76
  <html lang="en">
27
77
  <head>
@@ -0,0 +1,60 @@
1
+ // @bun
2
+ // ../../src/core/mail/sanitizeMailHtml.ts
3
+ var ALLOWED_TAGS = new Set([
4
+ "a",
5
+ "blockquote",
6
+ "br",
7
+ "code",
8
+ "em",
9
+ "h1",
10
+ "h2",
11
+ "h3",
12
+ "h4",
13
+ "h5",
14
+ "h6",
15
+ "hr",
16
+ "li",
17
+ "ol",
18
+ "p",
19
+ "pre",
20
+ "strong",
21
+ "ul"
22
+ ]);
23
+ function escapeHtml(value) {
24
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
25
+ }
26
+ function decodeBasicEntities(value) {
27
+ return value.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
28
+ }
29
+ function isSafeHref(value) {
30
+ return /^(https?:|mailto:)/i.test(value.trim());
31
+ }
32
+ function sanitizeAttributes(tagName, rawAttributes) {
33
+ if (tagName !== "a") {
34
+ return "";
35
+ }
36
+ const hrefMatch = rawAttributes.match(/\bhref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
37
+ const href = decodeBasicEntities(hrefMatch?.[2] ?? hrefMatch?.[3] ?? hrefMatch?.[4] ?? "");
38
+ if (!href || !isSafeHref(href)) {
39
+ return "";
40
+ }
41
+ return ` href="${escapeHtml(href)}"`;
42
+ }
43
+ function sanitizeMailHtml(html) {
44
+ return html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (match, tagName, rawAttributes) => {
45
+ const name = tagName.toLowerCase();
46
+ if (!ALLOWED_TAGS.has(name)) {
47
+ return "";
48
+ }
49
+ if (match.startsWith("</")) {
50
+ return `</${name}>`;
51
+ }
52
+ if (match.endsWith("/>") || name === "br" || name === "hr") {
53
+ return `<${name}>`;
54
+ }
55
+ return `<${name}${sanitizeAttributes(name, rawAttributes)}>`;
56
+ });
57
+ }
58
+ export {
59
+ sanitizeMailHtml
60
+ };
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  // ../../src/config/app.ts
3
3
  var appConfig = {
4
- name: "WorkHub",
4
+ name: process.env.APP_NAME?.trim() || "WorkHub",
5
5
  env: process.env.APP_ENV ?? "local",
6
6
  debug: (process.env.APP_DEBUG ?? "true") !== "false",
7
7
  url: process.env.APP_URL ?? "http://localhost:3000",
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  // ../../src/config/app.ts
3
3
  var appConfig = {
4
- name: "WorkHub",
4
+ name: process.env.APP_NAME?.trim() || "WorkHub",
5
5
  env: process.env.APP_ENV ?? "local",
6
6
  debug: (process.env.APP_DEBUG ?? "true") !== "false",
7
7
  url: process.env.APP_URL ?? "http://localhost:3000",