@actuate-media/cms-core 0.11.2 → 0.13.0

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 (115) hide show
  1. package/dist/__tests__/api/cron-routes.test.d.ts +2 -0
  2. package/dist/__tests__/api/cron-routes.test.d.ts.map +1 -0
  3. package/dist/__tests__/api/cron-routes.test.js +67 -0
  4. package/dist/__tests__/api/cron-routes.test.js.map +1 -0
  5. package/dist/__tests__/api/health.test.d.ts +2 -0
  6. package/dist/__tests__/api/health.test.d.ts.map +1 -0
  7. package/dist/__tests__/api/health.test.js +140 -0
  8. package/dist/__tests__/api/health.test.js.map +1 -0
  9. package/dist/__tests__/auth/oauth.test.d.ts +2 -0
  10. package/dist/__tests__/auth/oauth.test.d.ts.map +1 -0
  11. package/dist/__tests__/auth/oauth.test.js +406 -0
  12. package/dist/__tests__/auth/oauth.test.js.map +1 -0
  13. package/dist/__tests__/auth/password.test.js +82 -3
  14. package/dist/__tests__/auth/password.test.js.map +1 -1
  15. package/dist/__tests__/auth/reset.test.d.ts +2 -0
  16. package/dist/__tests__/auth/reset.test.d.ts.map +1 -0
  17. package/dist/__tests__/auth/reset.test.js +303 -0
  18. package/dist/__tests__/auth/reset.test.js.map +1 -0
  19. package/dist/__tests__/auth/session.test.js +54 -1
  20. package/dist/__tests__/auth/session.test.js.map +1 -1
  21. package/dist/__tests__/cron/cron.test.d.ts +2 -0
  22. package/dist/__tests__/cron/cron.test.d.ts.map +1 -0
  23. package/dist/__tests__/cron/cron.test.js +262 -0
  24. package/dist/__tests__/cron/cron.test.js.map +1 -0
  25. package/dist/__tests__/diagnostics/env.test.d.ts +2 -0
  26. package/dist/__tests__/diagnostics/env.test.d.ts.map +1 -0
  27. package/dist/__tests__/diagnostics/env.test.js +119 -0
  28. package/dist/__tests__/diagnostics/env.test.js.map +1 -0
  29. package/dist/__tests__/diagnostics/logger.test.d.ts +2 -0
  30. package/dist/__tests__/diagnostics/logger.test.d.ts.map +1 -0
  31. package/dist/__tests__/diagnostics/logger.test.js +111 -0
  32. package/dist/__tests__/diagnostics/logger.test.js.map +1 -0
  33. package/dist/__tests__/security/encrypted-fields.test.d.ts +2 -0
  34. package/dist/__tests__/security/encrypted-fields.test.d.ts.map +1 -0
  35. package/dist/__tests__/security/encrypted-fields.test.js +60 -0
  36. package/dist/__tests__/security/encrypted-fields.test.js.map +1 -0
  37. package/dist/__tests__/security/rate-limit.test.js +42 -0
  38. package/dist/__tests__/security/rate-limit.test.js.map +1 -1
  39. package/dist/__tests__/security/safe-fetch.test.d.ts +2 -0
  40. package/dist/__tests__/security/safe-fetch.test.d.ts.map +1 -0
  41. package/dist/__tests__/security/safe-fetch.test.js +97 -0
  42. package/dist/__tests__/security/safe-fetch.test.js.map +1 -0
  43. package/dist/__tests__/security/ssrf.test.d.ts +2 -0
  44. package/dist/__tests__/security/ssrf.test.d.ts.map +1 -0
  45. package/dist/__tests__/security/ssrf.test.js +209 -0
  46. package/dist/__tests__/security/ssrf.test.js.map +1 -0
  47. package/dist/actions.d.ts.map +1 -1
  48. package/dist/actions.js +7 -6
  49. package/dist/actions.js.map +1 -1
  50. package/dist/api/handler-factory.d.ts.map +1 -1
  51. package/dist/api/handler-factory.js +15 -6
  52. package/dist/api/handler-factory.js.map +1 -1
  53. package/dist/api/handlers.d.ts.map +1 -1
  54. package/dist/api/handlers.js +165 -26
  55. package/dist/api/handlers.js.map +1 -1
  56. package/dist/auth/oauth.d.ts +8 -0
  57. package/dist/auth/oauth.d.ts.map +1 -1
  58. package/dist/auth/oauth.js +44 -2
  59. package/dist/auth/oauth.js.map +1 -1
  60. package/dist/auth/password.d.ts +35 -2
  61. package/dist/auth/password.d.ts.map +1 -1
  62. package/dist/auth/password.js +97 -7
  63. package/dist/auth/password.js.map +1 -1
  64. package/dist/auth/reset.d.ts.map +1 -1
  65. package/dist/auth/reset.js +2 -1
  66. package/dist/auth/reset.js.map +1 -1
  67. package/dist/auth/session.d.ts +9 -0
  68. package/dist/auth/session.d.ts.map +1 -1
  69. package/dist/auth/session.js +54 -1
  70. package/dist/auth/session.js.map +1 -1
  71. package/dist/config/runtime.d.ts +99 -0
  72. package/dist/config/runtime.d.ts.map +1 -0
  73. package/dist/config/runtime.js +43 -0
  74. package/dist/config/runtime.js.map +1 -0
  75. package/dist/config/types.d.ts +21 -0
  76. package/dist/config/types.d.ts.map +1 -1
  77. package/dist/cron/index.d.ts +72 -0
  78. package/dist/cron/index.d.ts.map +1 -0
  79. package/dist/cron/index.js +222 -0
  80. package/dist/cron/index.js.map +1 -0
  81. package/dist/diagnostics/env.d.ts +44 -0
  82. package/dist/diagnostics/env.d.ts.map +1 -0
  83. package/dist/diagnostics/env.js +293 -0
  84. package/dist/diagnostics/env.js.map +1 -0
  85. package/dist/diagnostics/logger.d.ts +38 -0
  86. package/dist/diagnostics/logger.d.ts.map +1 -0
  87. package/dist/diagnostics/logger.js +89 -0
  88. package/dist/diagnostics/logger.js.map +1 -0
  89. package/dist/page-builder/blocks.d.ts.map +1 -1
  90. package/dist/page-builder/blocks.js +6 -1
  91. package/dist/page-builder/blocks.js.map +1 -1
  92. package/dist/security/audit.d.ts.map +1 -1
  93. package/dist/security/audit.js +3 -1
  94. package/dist/security/audit.js.map +1 -1
  95. package/dist/security/encrypted-fields.d.ts +9 -0
  96. package/dist/security/encrypted-fields.d.ts.map +1 -1
  97. package/dist/security/encrypted-fields.js +52 -1
  98. package/dist/security/encrypted-fields.js.map +1 -1
  99. package/dist/security/ip-canon.d.ts +71 -0
  100. package/dist/security/ip-canon.d.ts.map +1 -0
  101. package/dist/security/ip-canon.js +352 -0
  102. package/dist/security/ip-canon.js.map +1 -0
  103. package/dist/security/rate-limit.d.ts +8 -0
  104. package/dist/security/rate-limit.d.ts.map +1 -1
  105. package/dist/security/rate-limit.js +81 -3
  106. package/dist/security/rate-limit.js.map +1 -1
  107. package/dist/security/safe-fetch.d.ts +30 -8
  108. package/dist/security/safe-fetch.d.ts.map +1 -1
  109. package/dist/security/safe-fetch.js +32 -6
  110. package/dist/security/safe-fetch.js.map +1 -1
  111. package/dist/security/webhook.d.ts +20 -2
  112. package/dist/security/webhook.d.ts.map +1 -1
  113. package/dist/security/webhook.js +100 -30
  114. package/dist/security/webhook.js.map +1 -1
  115. package/package.json +1 -1
@@ -2,21 +2,27 @@
2
2
  * Perform a `fetch()` with SSRF defenses applied:
3
3
  *
4
4
  * - Reject non-http/https URLs (no `file:`, `gopher:`, `data:`).
5
- * - Reject hostnames that resolve to private/loopback IP literals
6
- * (`10.x`, `127.x`, `169.254.x`, `192.168.x`, `::1`, …).
7
- * - Reject `localhost` and `0.0.0.0`.
5
+ * - Reject hostnames that resolve to private/loopback IP literals via the
6
+ * canonicalizer in `ip-canon.ts` (decimal, octal, hex, IPv4-mapped IPv6,
7
+ * short-form, CGNAT, cloud metadata, etc.).
8
+ * - Reject `localhost`, `0.0.0.0`, and `*.internal` hostnames.
9
+ * - DNS-resolve the hostname before fetching and reject if any A/AAAA record
10
+ * lands in a private range. This defeats DNS rebinding — without it, an
11
+ * attacker controlling DNS for `evil.tld` returns `8.8.8.8` to our
12
+ * validator and `127.0.0.1` to the actual `fetch`. The pre-resolution
13
+ * forces the same lookup.
8
14
  * - Disable HTTP redirects by default (`redirect: 'manual'`) — a 302 to an
9
15
  * internal address would otherwise smuggle the request past the validator.
16
+ * When `followRedirects: true`, each hop is re-validated AND re-resolved.
10
17
  * - Apply a hard request timeout via `AbortSignal.timeout`.
11
18
  *
12
19
  * Use this for any internal `fetch()` call that targets a URL derived from
13
20
  * user/admin input (webhooks, link health, image URL fetches, etc).
14
21
  *
15
- * Note: this guards against the URL **as written**. For full DNS-rebinding
16
- * protection use `resolveAndCheck()` from `./webhook.js` to pre-resolve the
17
- * hostname; on Node 20+ this can be combined with a custom dispatcher to bind
18
- * the request to the resolved IP. The default policy here trades that
19
- * protection for portability across runtimes (Edge, Bun, Workers).
22
+ * **Runtime caveat:** DNS resolution uses `node:dns/promises`. On Edge / Bun
23
+ * / Workers without that module, `resolveDns` is skipped and only the
24
+ * URL-string check applies set `requireDnsCheck: true` to fail closed on
25
+ * runtimes where the resolver isn't available.
20
26
  */
21
27
  export interface SafeFetchOptions extends RequestInit {
22
28
  /** Hard timeout applied via AbortSignal. Defaults to 5000ms. */
@@ -25,6 +31,22 @@ export interface SafeFetchOptions extends RequestInit {
25
31
  followRedirects?: boolean;
26
32
  /** Maximum number of redirect hops when followRedirects is true. Default: 3. */
27
33
  maxRedirects?: number;
34
+ /**
35
+ * When true, DNS resolution failures (including missing `node:dns/promises`)
36
+ * cause the request to fail closed with an `SsrfBlockedError` instead of
37
+ * proceeding with only the string-level check. Default: false (best effort
38
+ * — preserves portability across Edge/Workers runtimes).
39
+ */
40
+ requireDnsCheck?: boolean;
41
+ /**
42
+ * Test seam — allows tests to substitute a fake resolver instead of hitting
43
+ * real DNS. Production callers leave this undefined.
44
+ */
45
+ _resolver?: (hostname: string) => Promise<{
46
+ safe: boolean;
47
+ resolvedIp?: string;
48
+ error?: string;
49
+ }>;
28
50
  }
29
51
  export declare class SsrfBlockedError extends Error {
30
52
  readonly url: string;
@@ -1 +1 @@
1
- {"version":3,"file":"safe-fetch.d.ts","sourceRoot":"","sources":["../../src/security/safe-fetch.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sFAAsF;IACtF,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,gFAAgF;IAChF,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;gBACX,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAMxC;AAED,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAsC9F"}
1
+ {"version":3,"file":"safe-fetch.d.ts","sourceRoot":"","sources":["../../src/security/safe-fetch.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sFAAsF;IACtF,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,gFAAgF;IAChF,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAClG;AAED,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;gBACX,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAMxC;AAuCD,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,QAAQ,CAAC,CA0C9F"}
@@ -1,4 +1,4 @@
1
- import { validateWebhookUrl } from './webhook.js';
1
+ import { resolveAndCheck, validateWebhookUrl } from './webhook.js';
2
2
  export class SsrfBlockedError extends Error {
3
3
  url;
4
4
  reason;
@@ -9,15 +9,41 @@ export class SsrfBlockedError extends Error {
9
9
  this.reason = reason;
10
10
  }
11
11
  }
12
+ async function preflightUrl(url, options) {
13
+ const check = validateWebhookUrl(url);
14
+ if (!check.valid) {
15
+ throw new SsrfBlockedError(url, check.error ?? 'URL rejected by SSRF policy');
16
+ }
17
+ let parsed;
18
+ try {
19
+ parsed = new URL(url);
20
+ }
21
+ catch {
22
+ throw new SsrfBlockedError(url, 'invalid URL');
23
+ }
24
+ // resolveAndCheck short-circuits when the hostname is already an IP literal
25
+ // (which validateWebhookUrl just covered), so this only does real DNS work
26
+ // for actual hostnames.
27
+ let resolved;
28
+ try {
29
+ resolved = await options.resolver(parsed.hostname);
30
+ }
31
+ catch (err) {
32
+ if (options.requireDnsCheck) {
33
+ throw new SsrfBlockedError(url, `DNS resolution failed: ${err instanceof Error ? err.message : String(err)}`);
34
+ }
35
+ return; // best-effort mode: proceed with string check only
36
+ }
37
+ if (!resolved.safe) {
38
+ throw new SsrfBlockedError(url, resolved.error ?? 'hostname resolves to a private IP');
39
+ }
40
+ }
12
41
  export async function safeFetch(url, options = {}) {
13
- const { timeoutMs = 5000, followRedirects = false, maxRedirects = 3, ...init } = options;
42
+ const { timeoutMs = 5000, followRedirects = false, maxRedirects = 3, requireDnsCheck = false, _resolver = resolveAndCheck, ...init } = options;
14
43
  let currentUrl = url;
15
44
  let hops = 0;
16
45
  while (true) {
17
- const check = validateWebhookUrl(currentUrl);
18
- if (!check.valid) {
19
- throw new SsrfBlockedError(currentUrl, check.error ?? 'URL rejected by SSRF policy');
20
- }
46
+ await preflightUrl(currentUrl, { requireDnsCheck, resolver: _resolver });
21
47
  const response = await fetch(currentUrl, {
22
48
  ...init,
23
49
  redirect: 'manual',
@@ -1 +1 @@
1
- {"version":3,"file":"safe-fetch.js","sourceRoot":"","sources":["../../src/security/safe-fetch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAA;AA+BjD,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IAChC,GAAG,CAAQ;IACX,MAAM,CAAQ;IACvB,YAAY,GAAW,EAAE,MAAc;QACrC,KAAK,CAAC,iBAAiB,MAAM,SAAS,GAAG,GAAG,CAAC,CAAA;QAC7C,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAA;QAC9B,IAAI,CAAC,GAAG,GAAG,GAAG,CAAA;QACd,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;IACtB,CAAC;CACF;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,GAAW,EAAE,UAA4B,EAAE;IACzE,MAAM,EAAE,SAAS,GAAG,IAAI,EAAE,eAAe,GAAG,KAAK,EAAE,YAAY,GAAG,CAAC,EAAE,GAAG,IAAI,EAAE,GAAG,OAAO,CAAA;IAExF,IAAI,UAAU,GAAG,GAAG,CAAA;IACpB,IAAI,IAAI,GAAG,CAAC,CAAA;IAEZ,OAAO,IAAI,EAAE,CAAC;QACZ,MAAM,KAAK,GAAG,kBAAkB,CAAC,UAAU,CAAC,CAAA;QAC5C,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;YACjB,MAAM,IAAI,gBAAgB,CAAC,UAAU,EAAE,KAAK,CAAC,KAAK,IAAI,6BAA6B,CAAC,CAAA;QACtF,CAAC;QAED,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,UAAU,EAAE;YACvC,GAAG,IAAI;YACP,QAAQ,EAAE,QAAQ;YAClB,MAAM,EAAE,IAAI,CAAC,MAAM,IAAI,WAAW,CAAC,OAAO,CAAC,SAAS,CAAC;SACtD,CAAC,CAAA;QAEF,MAAM,UAAU,GAAG,QAAQ,CAAC,MAAM,IAAI,GAAG,IAAI,QAAQ,CAAC,MAAM,GAAG,GAAG,CAAA;QAClE,IAAI,CAAC,UAAU,IAAI,CAAC,eAAe,EAAE,CAAC;YACpC,OAAO,QAAQ,CAAA;QACjB,CAAC;QAED,IAAI,IAAI,IAAI,YAAY,EAAE,CAAC;YACzB,MAAM,IAAI,gBAAgB,CAAC,UAAU,EAAE,YAAY,YAAY,YAAY,CAAC,CAAA;QAC9E,CAAC;QAED,MAAM,QAAQ,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,CAAA;QACjD,IAAI,CAAC,QAAQ;YAAE,OAAO,QAAQ,CAAA;QAE9B,IAAI,CAAC;YACH,UAAU,GAAG,IAAI,GAAG,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC,QAAQ,EAAE,CAAA;QACvD,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,IAAI,gBAAgB,CAAC,QAAQ,EAAE,yBAAyB,CAAC,CAAA;QACjE,CAAC;QAED,IAAI,IAAI,CAAC,CAAA;IACX,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"safe-fetch.js","sourceRoot":"","sources":["../../src/security/safe-fetch.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,27 @@
1
- /** Validate that a webhook URL does not target private/internal networks (SSRF prevention). */
1
+ /**
2
+ * Validate that a webhook URL does not target private/internal networks
3
+ * (string-level SSRF prevention).
4
+ *
5
+ * Catches every IP-literal encoding `getaddrinfo` accepts: decimal
6
+ * (`http://2130706433`), octal (`http://0177.0.0.1`), hex
7
+ * (`http://0x7f.0.0.1`), short-form (`http://127.1`), IPv4-mapped IPv6
8
+ * (`http://[::ffff:127.0.0.1]`), bracketed IPv6, and the reserved
9
+ * `100.64.0.0/10` carrier-grade NAT range that previously slipped through.
10
+ *
11
+ * **Important caveat:** this only validates the URL **as written**. A hostname
12
+ * like `attacker-controlled.tld` that resolves to a public IP at validation
13
+ * time and to `127.0.0.1` at fetch time (DNS rebinding) still bypasses this
14
+ * check — `safeFetch` calls `resolveAndCheck()` on top to defend against that.
15
+ */
2
16
  export declare function validateWebhookUrl(url: string): {
3
17
  valid: boolean;
4
18
  error?: string;
5
19
  };
6
- /** Resolve a hostname and verify the resulting IP isn't in a private range. */
20
+ /**
21
+ * Resolve a hostname's A/AAAA records and verify none of them land in a
22
+ * private range. Pair with `validateWebhookUrl` to defend against DNS
23
+ * rebinding — see `safeFetch`.
24
+ */
7
25
  export declare function resolveAndCheck(hostname: string): Promise<{
8
26
  safe: boolean;
9
27
  resolvedIp?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../../src/security/webhook.ts"],"names":[],"mappings":"AAYA,+FAA+F;AAC/F,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAsBlF;AAED,+EAA+E;AAC/E,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA8BjE"}
1
+ {"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../../src/security/webhook.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAmDlF;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAgEjE"}
@@ -1,37 +1,90 @@
1
- const PRIVATE_RANGES = [
2
- /^10\./,
3
- /^172\.(1[6-9]|2\d|3[01])\./,
4
- /^192\.168\./,
5
- /^127\./,
6
- /^0\./,
7
- /^169\.254\./,
8
- /^::1$/,
9
- /^fc00:/i,
10
- /^fe80:/i,
11
- ];
12
- /** Validate that a webhook URL does not target private/internal networks (SSRF prevention). */
1
+ import { canonicalizeHostname, isPrivateAddress } from './ip-canon.js';
2
+ /**
3
+ * Validate that a webhook URL does not target private/internal networks
4
+ * (string-level SSRF prevention).
5
+ *
6
+ * Catches every IP-literal encoding `getaddrinfo` accepts: decimal
7
+ * (`http://2130706433`), octal (`http://0177.0.0.1`), hex
8
+ * (`http://0x7f.0.0.1`), short-form (`http://127.1`), IPv4-mapped IPv6
9
+ * (`http://[::ffff:127.0.0.1]`), bracketed IPv6, and the reserved
10
+ * `100.64.0.0/10` carrier-grade NAT range that previously slipped through.
11
+ *
12
+ * **Important caveat:** this only validates the URL **as written**. A hostname
13
+ * like `attacker-controlled.tld` that resolves to a public IP at validation
14
+ * time and to `127.0.0.1` at fetch time (DNS rebinding) still bypasses this
15
+ * check — `safeFetch` calls `resolveAndCheck()` on top to defend against that.
16
+ */
13
17
  export function validateWebhookUrl(url) {
18
+ let parsed;
14
19
  try {
15
- const parsed = new URL(url);
16
- if (!['https:', 'http:'].includes(parsed.protocol)) {
17
- return { valid: false, error: 'Only HTTP(S) protocols are allowed' };
18
- }
19
- if (parsed.hostname === 'localhost' || parsed.hostname === '0.0.0.0') {
20
- return { valid: false, error: 'Localhost URLs are not allowed' };
21
- }
22
- for (const range of PRIVATE_RANGES) {
23
- if (range.test(parsed.hostname)) {
24
- return { valid: false, error: 'Private/internal IP addresses are not allowed' };
25
- }
26
- }
27
- return { valid: true };
20
+ parsed = new URL(url);
28
21
  }
29
22
  catch {
30
23
  return { valid: false, error: 'Invalid URL' };
31
24
  }
25
+ if (!['https:', 'http:'].includes(parsed.protocol)) {
26
+ return { valid: false, error: 'Only HTTP(S) protocols are allowed' };
27
+ }
28
+ // Special-case the well-known names — they're DNS hostnames so the
29
+ // canonicalizer can't catch them via numeric ranges.
30
+ const lowerHost = parsed.hostname.toLowerCase();
31
+ if (lowerHost === 'localhost' || lowerHost === 'ip6-localhost' || lowerHost === 'ip6-loopback') {
32
+ return { valid: false, error: 'Localhost hostnames are not allowed' };
33
+ }
34
+ // metadata.google.internal and AWS' internal DNS shadow real metadata IPs
35
+ // we already block — but the hostname can be redirected via /etc/hosts in
36
+ // self-hosted contexts, so block it explicitly.
37
+ if (lowerHost === 'metadata.google.internal' || lowerHost.endsWith('.internal')) {
38
+ return { valid: false, error: 'Cloud metadata hostnames are not allowed' };
39
+ }
40
+ const canonical = canonicalizeHostname(parsed.hostname);
41
+ if (!canonical.isHostname) {
42
+ // Three sub-cases for `!isHostname`:
43
+ // 1. Valid IP literal that's private → reject with the range reason.
44
+ // 2. Valid IP literal that's public → allow (fall through).
45
+ // 3. Malformed IP literal (e.g. `::1::1`, IPv6 with garbage) →
46
+ // `isValidIp` is false. Fail closed: the URL was clearly not a
47
+ // DNS hostname, so a `getaddrinfo` will reject it too — but
48
+ // worse, on some platforms it may resolve to *something*. Reject
49
+ // here rather than risk that ambiguity.
50
+ if (!canonical.isValidIp) {
51
+ return {
52
+ valid: false,
53
+ error: `Malformed IP literal in URL hostname: ${parsed.hostname}`,
54
+ };
55
+ }
56
+ const denied = isPrivateAddress(canonical);
57
+ if (denied) {
58
+ return {
59
+ valid: false,
60
+ error: `Private/internal IP address rejected: ${denied.reason}`,
61
+ };
62
+ }
63
+ }
64
+ return { valid: true };
32
65
  }
33
- /** Resolve a hostname and verify the resulting IP isn't in a private range. */
66
+ /**
67
+ * Resolve a hostname's A/AAAA records and verify none of them land in a
68
+ * private range. Pair with `validateWebhookUrl` to defend against DNS
69
+ * rebinding — see `safeFetch`.
70
+ */
34
71
  export async function resolveAndCheck(hostname) {
72
+ // If the hostname is already an IP literal, skip DNS and check directly.
73
+ const directCanonical = canonicalizeHostname(hostname);
74
+ if (!directCanonical.isHostname) {
75
+ // Malformed IP literal — treat as unsafe. Without this, a bad IPv6
76
+ // string (e.g. `::1::garbage`) would slip through the SSRF gate
77
+ // because `isPrivateAddress` legitimately returns null when there's
78
+ // no parsed IP to check.
79
+ if (!directCanonical.isValidIp) {
80
+ return { safe: false, resolvedIp: hostname, error: 'malformed IP literal' };
81
+ }
82
+ const denied = isPrivateAddress(directCanonical);
83
+ if (denied) {
84
+ return { safe: false, resolvedIp: hostname, error: denied.reason };
85
+ }
86
+ return { safe: true, resolvedIp: hostname };
87
+ }
35
88
  const { resolve4, resolve6 } = await import('node:dns/promises');
36
89
  const ips = [];
37
90
  try {
@@ -51,11 +104,28 @@ export async function resolveAndCheck(hostname) {
51
104
  if (ips.length === 0) {
52
105
  return { safe: false, error: `DNS resolution failed for ${hostname}` };
53
106
  }
107
+ // Check EVERY resolved IP, not just the first — a multi-A-record response
108
+ // could mix public + private IPs and we must reject if any single one is
109
+ // private to defeat round-robin DNS rebinding.
54
110
  for (const ip of ips) {
55
- for (const range of PRIVATE_RANGES) {
56
- if (range.test(ip)) {
57
- return { safe: false, resolvedIp: ip, error: `Resolved IP ${ip} is in a private range` };
58
- }
111
+ const c = canonicalizeHostname(ip);
112
+ // DNS resolvers return canonical IP literals, so `isValidIp` should be
113
+ // true. If a resolver ever returns something we can't parse, treat it
114
+ // as unsafe rather than trusting it.
115
+ if (!c.isValidIp) {
116
+ return {
117
+ safe: false,
118
+ resolvedIp: ip,
119
+ error: `Unparseable IP returned from DNS for ${hostname}: ${ip}`,
120
+ };
121
+ }
122
+ const denied = isPrivateAddress(c);
123
+ if (denied) {
124
+ return {
125
+ safe: false,
126
+ resolvedIp: ip,
127
+ error: `Resolved IP ${ip} is in a private range: ${denied.reason}`,
128
+ };
59
129
  }
60
130
  }
61
131
  return { safe: true, resolvedIp: ips[0] };
@@ -1 +1 @@
1
- {"version":3,"file":"webhook.js","sourceRoot":"","sources":["../../src/security/webhook.ts"],"names":[],"mappings":"AAAA,MAAM,cAAc,GAAG;IACrB,OAAO;IACP,4BAA4B;IAC5B,aAAa;IACb,QAAQ;IACR,MAAM;IACN,aAAa;IACb,OAAO;IACP,SAAS;IACT,SAAS;CACV,CAAA;AAED,+FAA+F;AAC/F,MAAM,UAAU,kBAAkB,CAAC,GAAW;IAC5C,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAA;QAE3B,IAAI,CAAC,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;YACnD,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,oCAAoC,EAAE,CAAA;QACtE,CAAC;QAED,IAAI,MAAM,CAAC,QAAQ,KAAK,WAAW,IAAI,MAAM,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;YACrE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,gCAAgC,EAAE,CAAA;QAClE,CAAC;QAED,KAAK,MAAM,KAAK,IAAI,cAAc,EAAE,CAAC;YACnC,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAChC,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,+CAA+C,EAAE,CAAA;YACjF,CAAC;QACH,CAAC;QAED,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;IACxB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,aAAa,EAAE,CAAA;IAC/C,CAAC;AACH,CAAC;AAED,+EAA+E;AAC/E,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,QAAgB;IAEhB,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,MAAM,MAAM,CAAC,mBAAmB,CAAC,CAAA;IAEhE,MAAM,GAAG,GAAa,EAAE,CAAA;IACxB,IAAI,CAAC;QACH,MAAM,EAAE,GAAG,MAAM,QAAQ,CAAC,QAAQ,CAAC,CAAA;QACnC,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAA;IACjB,CAAC;IAAC,MAAM,CAAC;QACP,kBAAkB;IACpB,CAAC;IACD,IAAI,CAAC;QACH,MAAM,EAAE,GAAG,MAAM,QAAQ,CAAC,QAAQ,CAAC,CAAA;QACnC,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAA;IACjB,CAAC;IAAC,MAAM,CAAC;QACP,qBAAqB;IACvB,CAAC;IAED,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACrB,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,6BAA6B,QAAQ,EAAE,EAAE,CAAA;IACxE,CAAC;IAED,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC;QACrB,KAAK,MAAM,KAAK,IAAI,cAAc,EAAE,CAAC;YACnC,IAAI,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC;gBACnB,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,UAAU,EAAE,EAAE,EAAE,KAAK,EAAE,eAAe,EAAE,wBAAwB,EAAE,CAAA;YAC1F,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,CAAA;AAC3C,CAAC"}
1
+ {"version":3,"file":"webhook.js","sourceRoot":"","sources":["../../src/security/webhook.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-core",
3
- "version": "0.11.2",
3
+ "version": "0.13.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",