@spfn/notification 0.1.0-beta.21 → 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 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
- if (hmacEncoded !== expectedHmacEncoded) {
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
+ "&": "&amp;",
393
+ "<": "&lt;",
394
+ ">": "&gt;",
395
+ '"': "&quot;",
396
+ "'": "&#39;"
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
- let value = getValue(data, variable);
419
+ const value = getValue(data, variable);
399
420
  if (value === void 0) {
400
421
  return match;
401
422
  }
402
- if (filter && filters[filter]) {
403
- return filters[filter](value, arg);
423
+ if (filter === "raw") {
424
+ return String(value);
404
425
  }
405
- return String(value);
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
- html: template.html ? render(template.html, data) : void 0,
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 && result.notificationId != null && result.linkIndex != null) {
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: {