@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.
- package/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/dist/core/http/contentSecurityPolicy.d.ts +27 -0
- package/dist/core/http/requestMetaContext.d.ts +1 -0
- package/dist/core/http/safeInternalPath.d.ts +4 -0
- package/dist/core/http/securedRouteModelBinding.d.ts +2 -0
- package/dist/core/http/securityHeadersMiddleware.d.ts +5 -2
- package/dist/core/http/signedUrl.d.ts +11 -0
- package/dist/core/http/webErrorResponse.d.ts +1 -1
- package/dist/core/jobs/exportAuditLogsJob.d.ts +12 -0
- package/dist/core/mail/sanitizeMailHtml.d.ts +2 -0
- package/dist/core/security/totp.d.ts +7 -1
- package/dist/core/view/etaViewEngine.d.ts +3 -2
- package/dist/core/view/htmlResponse.d.ts +1 -2
- package/dist/core/view/index.d.ts +4 -1
- package/dist/core/view/viewEngine.d.ts +1 -0
- package/dist/core/view/webErrorView.d.ts +21 -0
- package/dist/entries/audit/exportAuditLogs.js +1 -1
- package/dist/entries/http/contentSecurityPolicy.js +1 -0
- package/dist/entries/http/requireAbilityMiddleware.js +44 -1
- package/dist/entries/http/requireWebAuthMiddleware.js +36 -3
- package/dist/entries/http/response.js +127 -555
- package/dist/entries/http/safeInternalPath.js +38 -0
- package/dist/entries/http/securedRouteModelBinding.js +27 -5
- package/dist/entries/http/securityHeadersMiddleware.js +34 -61
- package/dist/entries/http/signedUrl.js +97 -0
- package/dist/entries/http/webErrorResponse.js +126 -554
- package/dist/entries/jobs/dispatchWebhookJob.js +14 -12
- package/dist/entries/jobs/exportAuditLogsJob.js +340 -0
- package/dist/entries/logging/requestLoggingMiddleware.js +2 -1
- package/dist/entries/mail/markdownMail.js +62 -12
- package/dist/entries/mail/markdownMailable.js +62 -12
- package/dist/entries/mail/sanitizeMailHtml.js +60 -0
- package/dist/entries/openapi/generator.js +1 -1
- package/dist/entries/security/safeFetch.js +1 -1
- package/dist/entries/security/totp.js +31 -1
- package/dist/entries/view.js +88 -8
- package/dist/framework/public-api.d.ts +6 -1
- package/dist/index.js +744 -349
- package/package.json +27 -2
- 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
|
-
|
|
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
|
-
|
|
305
|
+
[signatureHeader]: signature
|
|
304
306
|
},
|
|
305
307
|
body
|
|
306
|
-
}, { allowHttp
|
|
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/
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
5
25
|
}
|
|
26
|
+
function decodeBasicEntities(value) {
|
|
27
|
+
return value.replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
-
|
|
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 =
|
|
23
|
-
const preview =
|
|
24
|
-
const footer =
|
|
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/
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
5
25
|
}
|
|
26
|
+
function decodeBasicEntities(value) {
|
|
27
|
+
return value.replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
-
|
|
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 =
|
|
23
|
-
const preview =
|
|
24
|
-
const footer =
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
25
|
+
}
|
|
26
|
+
function decodeBasicEntities(value) {
|
|
27
|
+
return value.replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/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",
|