@getstrata/core 0.5.61 → 0.5.63
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 +16 -0
- package/README.md +1 -1
- package/dist/core/http/flashSession.d.ts +3 -1
- package/dist/core/http/signedUrl.d.ts +11 -0
- 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/entries/audit/exportAuditLogs.js +1 -1
- package/dist/entries/http/flashMiddleware.js +6 -3
- package/dist/entries/http/flashSession.js +8 -3
- package/dist/entries/http/requireAbilityMiddleware.js +44 -1
- package/dist/entries/http/requireWebAuthMiddleware.js +2 -1
- package/dist/entries/http/securityHeadersMiddleware.js +1 -1
- package/dist/entries/http/signedUrl.js +97 -0
- package/dist/entries/jobs/dispatchWebhookJob.js +14 -12
- package/dist/entries/jobs/exportAuditLogsJob.js +340 -0
- 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 +6 -3
- package/dist/framework/public-api.d.ts +2 -0
- package/dist/index.js +166 -17
- package/package.json +17 -2
|
@@ -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,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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/core/security/totp.ts
|
|
3
|
-
import { createHmac } from "crypto";
|
|
3
|
+
import { createHmac, randomBytes } from "crypto";
|
|
4
4
|
function decodeBase32(input) {
|
|
5
5
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
6
6
|
const normalized = input.replace(/=+$/u, "").toUpperCase();
|
|
@@ -18,6 +18,34 @@ function decodeBase32(input) {
|
|
|
18
18
|
}
|
|
19
19
|
return Buffer.from(bytes);
|
|
20
20
|
}
|
|
21
|
+
function encodeBase32(bytes) {
|
|
22
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
23
|
+
let bits = "";
|
|
24
|
+
for (const byte of bytes) {
|
|
25
|
+
bits += byte.toString(2).padStart(8, "0");
|
|
26
|
+
}
|
|
27
|
+
let output = "";
|
|
28
|
+
for (let index = 0;index < bits.length; index += 5) {
|
|
29
|
+
const chunk = bits.slice(index, index + 5).padEnd(5, "0");
|
|
30
|
+
output += alphabet[Number.parseInt(chunk, 2)] ?? "";
|
|
31
|
+
}
|
|
32
|
+
return output;
|
|
33
|
+
}
|
|
34
|
+
function generateTotpSecret(byteLength = 20) {
|
|
35
|
+
return encodeBase32(randomBytes(byteLength));
|
|
36
|
+
}
|
|
37
|
+
function buildOtpauthUrl(options) {
|
|
38
|
+
const issuer = options.issuer?.trim() || process.env.APP_NAME?.trim() || "WorkHub";
|
|
39
|
+
const label = `${issuer}:${options.account}`;
|
|
40
|
+
const params = new URLSearchParams({
|
|
41
|
+
secret: options.secret,
|
|
42
|
+
issuer,
|
|
43
|
+
algorithm: "SHA1",
|
|
44
|
+
digits: "6",
|
|
45
|
+
period: "30"
|
|
46
|
+
});
|
|
47
|
+
return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
|
|
48
|
+
}
|
|
21
49
|
function generateTotp(secret, counter, digits = 6) {
|
|
22
50
|
const key = decodeBase32(secret);
|
|
23
51
|
const buffer = Buffer.alloc(8);
|
|
@@ -46,6 +74,8 @@ function verifyTotp(secret, token, window = 1) {
|
|
|
46
74
|
return false;
|
|
47
75
|
}
|
|
48
76
|
export {
|
|
77
|
+
buildOtpauthUrl,
|
|
49
78
|
generateTotp,
|
|
79
|
+
generateTotpSecret,
|
|
50
80
|
verifyTotp
|
|
51
81
|
};
|
package/dist/entries/view.js
CHANGED
|
@@ -524,6 +524,9 @@ function resolveCsrfTokenForRequest(request) {
|
|
|
524
524
|
import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
525
525
|
var FLASH_COOKIE = "workhub_flash";
|
|
526
526
|
var FLASH_TTL_MS = 60 * 1000;
|
|
527
|
+
function flashCookieName() {
|
|
528
|
+
return process.env.FLASH_COOKIE_NAME?.trim() || FLASH_COOKIE;
|
|
529
|
+
}
|
|
527
530
|
function resolveFlashSecret() {
|
|
528
531
|
return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || "workhub-dev-flash-secret";
|
|
529
532
|
}
|
|
@@ -538,7 +541,7 @@ function readFlashCookie(request) {
|
|
|
538
541
|
}
|
|
539
542
|
for (const part of cookieHeader.split(";")) {
|
|
540
543
|
const [name, ...rest] = part.trim().split("=");
|
|
541
|
-
if (name ===
|
|
544
|
+
if (name === flashCookieName()) {
|
|
542
545
|
return decodeURIComponent(rest.join("="));
|
|
543
546
|
}
|
|
544
547
|
}
|
|
@@ -588,10 +591,10 @@ function createFlashCookie(message) {
|
|
|
588
591
|
const payload = Buffer.from(JSON.stringify(message), "utf8").toString("base64url");
|
|
589
592
|
const issuedAt = Date.now();
|
|
590
593
|
const value = signFlashPayload(payload, issuedAt);
|
|
591
|
-
return `${
|
|
594
|
+
return `${flashCookieName()}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60`;
|
|
592
595
|
}
|
|
593
596
|
function clearFlashCookie() {
|
|
594
|
-
return `${
|
|
597
|
+
return `${flashCookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
595
598
|
}
|
|
596
599
|
function pullFlash(request) {
|
|
597
600
|
const cookieValue = readFlashCookie(request);
|