@spfn/notification 0.1.0-beta.22 → 0.1.0-beta.23
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/dist/server.js +55 -19
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -206,7 +206,10 @@ var awsSesProvider = {
|
|
|
206
206
|
};
|
|
207
207
|
|
|
208
208
|
// src/tracking/token.ts
|
|
209
|
-
import { createHmac } from "crypto";
|
|
209
|
+
import { createHmac, createHash, timingSafeEqual } from "crypto";
|
|
210
|
+
function hashClickUrl(url) {
|
|
211
|
+
return createHash("sha256").update(url).digest("hex").slice(0, 16);
|
|
212
|
+
}
|
|
210
213
|
function toBase64Url(buffer) {
|
|
211
214
|
return buffer.toString("base64url");
|
|
212
215
|
}
|
|
@@ -237,7 +240,9 @@ function verify(token) {
|
|
|
237
240
|
const payload = fromBase64Url(payloadEncoded);
|
|
238
241
|
const expectedHmac = createHmac("sha256", secret).update(payload).digest();
|
|
239
242
|
const expectedHmacEncoded = toBase64Url(expectedHmac);
|
|
240
|
-
|
|
243
|
+
const provided = Buffer.from(hmacEncoded);
|
|
244
|
+
const expected = Buffer.from(expectedHmacEncoded);
|
|
245
|
+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
|
|
241
246
|
return { valid: false };
|
|
242
247
|
}
|
|
243
248
|
return { valid: true, payload };
|
|
@@ -245,8 +250,8 @@ function verify(token) {
|
|
|
245
250
|
function generateOpenToken(notificationId) {
|
|
246
251
|
return sign(`o:${notificationId}`);
|
|
247
252
|
}
|
|
248
|
-
function generateClickToken(notificationId, linkIndex) {
|
|
249
|
-
return sign(`c:${notificationId}:${linkIndex}`);
|
|
253
|
+
function generateClickToken(notificationId, linkIndex, url) {
|
|
254
|
+
return sign(`c:${notificationId}:${linkIndex}:${hashClickUrl(url)}`);
|
|
250
255
|
}
|
|
251
256
|
function verifyOpenToken(token) {
|
|
252
257
|
const result = verify(token);
|
|
@@ -264,14 +269,15 @@ function verifyClickToken(token) {
|
|
|
264
269
|
if (!result.valid || !result.payload) {
|
|
265
270
|
return { valid: false };
|
|
266
271
|
}
|
|
267
|
-
const match = result.payload.match(/^c:(\d+):(\d+)
|
|
272
|
+
const match = result.payload.match(/^c:(\d+):(\d+)(?::([0-9a-f]{16}))?$/);
|
|
268
273
|
if (!match) {
|
|
269
274
|
return { valid: false };
|
|
270
275
|
}
|
|
271
276
|
return {
|
|
272
277
|
valid: true,
|
|
273
278
|
notificationId: Number(match[1]),
|
|
274
|
-
linkIndex: Number(match[2])
|
|
279
|
+
linkIndex: Number(match[2]),
|
|
280
|
+
urlHash: match[3]
|
|
275
281
|
};
|
|
276
282
|
}
|
|
277
283
|
|
|
@@ -296,7 +302,7 @@ function processTrackingHtml(html, options) {
|
|
|
296
302
|
return match;
|
|
297
303
|
}
|
|
298
304
|
const currentIndex = linkIndex++;
|
|
299
|
-
const clickToken = generateClickToken(notificationId, currentIndex);
|
|
305
|
+
const clickToken = generateClickToken(notificationId, currentIndex, url);
|
|
300
306
|
const trackingUrl = `${baseUrl}/_noti/t/c/${clickToken}?url=${encodeURIComponent(url)}`;
|
|
301
307
|
trackedLinks.push({ index: currentIndex, url });
|
|
302
308
|
return `<a ${before}href="${trackingUrl}"${after}>`;
|
|
@@ -381,10 +387,24 @@ function parseExpression(expr) {
|
|
|
381
387
|
arg: filterPart.slice(colonIndex + 1)
|
|
382
388
|
};
|
|
383
389
|
}
|
|
390
|
+
var BLOCKED_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
391
|
+
var HTML_ESCAPES = {
|
|
392
|
+
"&": "&",
|
|
393
|
+
"<": "<",
|
|
394
|
+
">": ">",
|
|
395
|
+
'"': """,
|
|
396
|
+
"'": "'"
|
|
397
|
+
};
|
|
398
|
+
function escapeHtml(value) {
|
|
399
|
+
return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
|
|
400
|
+
}
|
|
384
401
|
function getValue(data, path) {
|
|
385
402
|
const parts = path.split(".");
|
|
386
403
|
let value = data;
|
|
387
404
|
for (const part of parts) {
|
|
405
|
+
if (BLOCKED_PATH_SEGMENTS.has(part)) {
|
|
406
|
+
return void 0;
|
|
407
|
+
}
|
|
388
408
|
if (value === null || value === void 0) {
|
|
389
409
|
return void 0;
|
|
390
410
|
}
|
|
@@ -392,17 +412,19 @@ function getValue(data, path) {
|
|
|
392
412
|
}
|
|
393
413
|
return value;
|
|
394
414
|
}
|
|
395
|
-
function render(template, data) {
|
|
415
|
+
function render(template, data, options = {}) {
|
|
416
|
+
const escape = options.escape ?? false;
|
|
396
417
|
return template.replace(/\{\{([^}]+)\}\}/g, (match, expr) => {
|
|
397
418
|
const { variable, filter, arg } = parseExpression(expr.trim());
|
|
398
|
-
|
|
419
|
+
const value = getValue(data, variable);
|
|
399
420
|
if (value === void 0) {
|
|
400
421
|
return match;
|
|
401
422
|
}
|
|
402
|
-
if (filter
|
|
403
|
-
return
|
|
423
|
+
if (filter === "raw") {
|
|
424
|
+
return String(value);
|
|
404
425
|
}
|
|
405
|
-
|
|
426
|
+
const rendered = filter && filters[filter] ? filters[filter](value, arg) : String(value);
|
|
427
|
+
return escape ? escapeHtml(rendered) : rendered;
|
|
406
428
|
});
|
|
407
429
|
}
|
|
408
430
|
function registerFilter(name, fn) {
|
|
@@ -444,7 +466,8 @@ function renderTemplate(name, data, channel) {
|
|
|
444
466
|
function renderEmailTemplate(template, data) {
|
|
445
467
|
return {
|
|
446
468
|
subject: render(template.subject, data),
|
|
447
|
-
|
|
469
|
+
// Escape interpolated values on the HTML path (caller data → markup injection).
|
|
470
|
+
html: template.html ? render(template.html, data, { escape: true }) : void 0,
|
|
448
471
|
text: template.text ? render(template.text, data) : void 0
|
|
449
472
|
};
|
|
450
473
|
}
|
|
@@ -4919,14 +4942,27 @@ var trackClick = route.get("/_noti/t/c/:token").input({
|
|
|
4919
4942
|
const { params, query } = await c.data();
|
|
4920
4943
|
const targetUrl = query.url;
|
|
4921
4944
|
const result = verifyClickToken(params.token);
|
|
4922
|
-
if (result.valid
|
|
4923
|
-
recordClickEvent(result.notificationId, result.linkIndex, targetUrl, {
|
|
4924
|
-
ipAddress: c.raw.req.header("x-forwarded-for") ?? c.raw.req.header("x-real-ip"),
|
|
4925
|
-
userAgent: c.raw.req.header("user-agent")
|
|
4926
|
-
});
|
|
4927
|
-
} else {
|
|
4945
|
+
if (!result.valid || result.notificationId == null || result.linkIndex == null) {
|
|
4928
4946
|
log8.warn("Invalid click tracking token");
|
|
4947
|
+
return new Response("Not found", { status: 404 });
|
|
4948
|
+
}
|
|
4949
|
+
let parsed;
|
|
4950
|
+
try {
|
|
4951
|
+
parsed = new URL(targetUrl);
|
|
4952
|
+
} catch {
|
|
4953
|
+
return new Response("Not found", { status: 404 });
|
|
4954
|
+
}
|
|
4955
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
4956
|
+
return new Response("Not found", { status: 404 });
|
|
4929
4957
|
}
|
|
4958
|
+
if (result.urlHash && hashClickUrl(targetUrl) !== result.urlHash) {
|
|
4959
|
+
log8.warn("Click URL does not match signed token");
|
|
4960
|
+
return new Response("Not found", { status: 404 });
|
|
4961
|
+
}
|
|
4962
|
+
recordClickEvent(result.notificationId, result.linkIndex, targetUrl, {
|
|
4963
|
+
ipAddress: c.raw.req.header("x-forwarded-for") ?? c.raw.req.header("x-real-ip"),
|
|
4964
|
+
userAgent: c.raw.req.header("user-agent")
|
|
4965
|
+
});
|
|
4930
4966
|
return new Response(null, {
|
|
4931
4967
|
status: 302,
|
|
4932
4968
|
headers: {
|