@rodit/rodit-auth-be 9.14.0 → 9.16.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  All notable changes to `@rodit/rodit-auth-be` are documented here.
4
4
 
5
+ ## [9.16.0] — 2026-09-14
6
+
7
+ ### Removed
8
+
9
+ - **`verify_rodit_isactive`** — DNS TXT revocation check
10
+ (`<token_id>.revoked.<domain>`). The function short-circuited with an
11
+ unconditional `return true` while debugging, so its body was unreachable and
12
+ no RODiT could be rejected as revoked. Removed along with its call sites in
13
+ `verify_peer_rodit` and `thorough_validate_jwt_token_be`, and the
14
+ `RODIT_REVOKED` / `SERVER_RODIT_REVOKED` failure codes they emitted.
15
+ No behavior change; the SDK now has no revocation check.
16
+ `verify_rodit_isactive_fe` in `@rodit/rodit-auth-fe` is unaffected.
17
+
18
+ ## [9.15.0] — 2026-08-11
19
+
20
+ ### Added
21
+
22
+ - **Outbound webhook SSRF controls** in `send_webhook` (CC-0001 residual):
23
+ before `fetch`, reject userinfo and private / loopback / link-local / ULA /
24
+ CGNAT / cloud-metadata hostnames and resolved A/AAAA addresses (same policy
25
+ as SLC `url-join-identity.js`); enforce JWT/peer `rodit_webhookcidr` when
26
+ present; resolve-once and pin the IP for the request via undici `lookup`.
27
+ `SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY` is unchanged for **public**
28
+ self-signed destinations.
29
+ - **`req.authenticatedRoditId` + login JSON `roditid`** after successful
30
+ `login_client` / `login_client_withnep413` signature verification (server-
31
+ derived peer token id only).
32
+ - **`enforceRateLimitFromClaims()`** middleware (and
33
+ `RoditClient#getClaimRateLimitMiddleware`) that consumes `req.rateLimit`
34
+ set by `validatepermissions`.
35
+
36
+ ### Security
37
+
38
+ - Compromised or DNS-rebound JWT `rodit_webhookurl` values can no longer reach
39
+ internal targets through the SDK outbound webhook path.
40
+
41
+ ## [9.14.1] — 2026-08-07
42
+
43
+ ### Changed
44
+
45
+ - Package version bumped to **9.14.1** (local `package.json` version restored).
46
+
5
47
  ## [9.14.0] — 2026-07-16
6
48
 
7
49
  ### Removed
package/README.md CHANGED
@@ -1,13 +1,27 @@
1
- # RODiT Authentication SDK
1
+ # @rodit/rodit-auth-be
2
2
 
3
- A comprehensive Node.js SDK for implementing RODiT-based mutual authentication, authorization, self-configuration, and session management in Express.js applications.
3
+ Node.js / Express SDK for RODiT-based mutual authentication, authorization, session management, and webhooks.
4
4
 
5
- **Version:** 9.13.0
6
- **License:** Proprietary
7
- **Author:** Discernible IO
5
+ **npm:** [https://www.npmjs.com/package/@rodit/rodit-auth-be](https://www.npmjs.com/package/@rodit/rodit-auth-be)
6
+ **Monorepo:** [discernible-io/rodit-sdk](https://github.com/discernible-io/rodit-sdk) (this directory)
7
+ **Version:** 9.15.0 · **License:** Proprietary · **Author:** Discernible IO
8
+
9
+ ## Production deployments
10
+
11
+ This package is the server runtime behind RODiT-powered APIs, including:
12
+
13
+ | Deployment | Role |
14
+ |------------|------|
15
+ | [api.identyclaw.com](https://api.identyclaw.com) | IdentyClaw platform API ([source: api-idc](https://github.com/discernible-io/api-idc)) |
16
+ | [api.lastcradle.io](https://api.lastcradle.io) | Last Cradle family API |
17
+ | Custom / federated APIs | Same SDK; sibling APIs in one token family support [federated login](#federated-login-same-family-different-api-url) |
18
+
19
+ Browser and wallet clients for these APIs use the companion package [`@rodit/rodit-auth-fe`](https://www.npmjs.com/package/@rodit/rodit-auth-fe).
8
20
 
9
21
  **Login `POST` /api/login:** Use **`accountid`** (or **`roditid`**), **`timestamp`**, and **`base64url_signature`**. Sign UTF-8 bytes of `identifier + timestamp_iso`, and reject deprecated keys such as **`signature`** and **`account_id`**. See [CHANGELOG.md](./CHANGELOG.md).
10
22
 
23
+ **9.15.0:** Outbound `send_webhook` SSRF controls (private/metadata blocklist, `webhook_cidr` enforcement, DNS pin); login success exposes verified peer id as JSON `roditid` and `req.authenticatedRoditId`; optional `enforceRateLimitFromClaims()` for per-route claim quotas. See [CHANGELOG.md](./CHANGELOG.md).
24
+
11
25
  **9.13 federated login:** A client RODiT issued for API A can log into API B in the same SR/CR family via `login_server({ apiEndpoint })`. See [Federated login](#federated-login-same-family-different-api-url).
12
26
 
13
27
  ## Table of Contents
@@ -23,6 +37,7 @@ A comprehensive Node.js SDK for implementing RODiT-based mutual authentication,
23
37
  - [Session lifetime and TTL](#session-lifetime-and-ttl)
24
38
  - [Configuration](#configuration)
25
39
  - [Environment Variables](#environment-variables)
40
+ - [Dynamic Rate Limiting](#dynamic-rate-limiting)
26
41
  - [Session Storage Configuration](#session-storage-configuration)
27
42
  - [Configuration Priority](#configuration-priority)
28
43
  - [Logging & Monitoring](#logging--monitoring)
@@ -605,26 +620,33 @@ Permissions are configured in your RODiT token metadata using the `permissioned_
605
620
  ```
606
621
 
607
622
  **Permission Format:**
608
- - `"+0"` = All methods allowed (GET, POST, PUT, DELETE, etc.)
609
- - `"+1"` = GET only
610
- - `"+2"` = POST only
611
- - Custom combinations can be defined
623
+
624
+ Values are strings: optional scope prefix (`+` / `-`) plus a numeric rate:
625
+
626
+ | Value | Meaning |
627
+ |-------|---------|
628
+ | `"+0"` | Allowed (entity + properties scope); **unlimited** rate |
629
+ | `"+60"` | Allowed; **60 requests** per window (default window 60s) |
630
+ | `"-0"` | Allowed (properties-only scope); unlimited |
631
+ | (absent / no match) | Denied (`403`) |
632
+
633
+ `validatepermissions` / `authorize` checks allow/deny and sets `req.rateLimit` from the numeric part. That metadata is inert until you mount `enforceRateLimitFromClaims()` (see [Dynamic Rate Limiting](#dynamic-rate-limiting)).
612
634
 
613
635
  ### Permission Validation Middleware
614
636
 
615
637
  The `authorize` middleware validates that the authenticated user has permission to access the requested route:
616
638
 
617
639
  ```javascript
618
- const authenticate = (req, res, next) => {
619
- req.app.locals.roditClient.authenticate(req, res, next)
620
- }
621
- const authorize = (req, res, next) => {
622
- req.app.locals.roditClient.authorize(req, res, next)
623
- }
624
- // Apply both authentication and authorization
640
+ // After RoditClient.create('server') and app.locals.roditClient = roditClient
641
+ const authenticate = (req, res, next) => roditClient.authenticate(req, res, next)
642
+ const authorize = (req, res, next) => roditClient.authorize(req, res, next)
643
+ const enforceClaimLimits = roditClient.getClaimRateLimitMiddleware()
644
+
645
+ // Auth + permission allow/deny
625
646
  app.use('/api/admin', authenticate, authorize, adminRoutes)
626
- // CRUDA endpoints with full protection
627
- app.use('/api/cruda', authenticate, authorize, crudaRoutes)
647
+
648
+ // Auth + permissions + per-route claim quotas from permissioned_routes
649
+ app.use('/api/cruda', authenticate, authorize, enforceClaimLimits(), crudaRoutes)
628
650
  ```
629
651
 
630
652
  ### Permission Enforcement
@@ -1186,16 +1208,33 @@ const dbPath = config.get('API_DEFAULT_OPTIONS.DB_PATH')
1186
1208
 
1187
1209
  ### Dynamic Rate Limiting
1188
1210
 
1211
+ Two complementary limiters:
1212
+
1213
+ 1. **Global** (`getRateLimitMiddleware` / `ratelimitmw`) — from own RODiT `max_requests` / `maxrq_window` (or fixed numbers). Applies across routes.
1214
+ 2. **Per-route claim** (`getClaimRateLimitMiddleware` / `enforceRateLimitFromClaims`) — consumes `req.rateLimit` set by `validatepermissions` from each peer’s `permissioned_routes` value (e.g. `"+60"` → 60 req / 60s for that path). Returns `429 RATE_LIMIT_EXCEEDED` when exceeded. In-memory, per process.
1215
+
1189
1216
  ```javascript
1190
- // Configure rate limiting from RODiT token
1217
+ // 1) Global limiter from own RODiT metadata
1191
1218
  const configObject = await roditClient.getConfigOwnRodit()
1192
1219
  const metadata = configObject.own_rodit.metadata
1193
1220
  if (metadata.max_requests && metadata.maxrq_window) {
1194
- const maxRequests = parseInt(metadata.max_requests)
1195
- const windowSeconds = parseInt(metadata.maxrq_window)
1221
+ const maxRequests = parseInt(metadata.max_requests, 10)
1222
+ const windowSeconds = parseInt(metadata.maxrq_window, 10)
1196
1223
  const rateLimiter = roditClient.getRateLimitMiddleware()
1197
- app.use(rateLimiter(maxRequests, windowSeconds))
1198
- }
1224
+ // Note: factory args are (maxRequests, windowMinutes)
1225
+ app.use(rateLimiter(maxRequests, Math.ceil(windowSeconds / 60) || 1))
1226
+ }
1227
+
1228
+ // 2) Per-route claim enforcement (after authenticate + validatepermissions)
1229
+ const { authenticate_apicall, validatepermissions, enforceRateLimitFromClaims } =
1230
+ require('@rodit/rodit-auth-be')
1231
+ // Or: roditClient.getClaimRateLimitMiddleware()
1232
+ app.use(
1233
+ '/api',
1234
+ (req, res, next) => roditClient.authenticate(req, res, next),
1235
+ validatepermissions,
1236
+ enforceRateLimitFromClaims()
1237
+ )
1199
1238
  ```
1200
1239
 
1201
1240
  ### Environment Variables
@@ -1249,8 +1288,8 @@ export LOKI_TLS_SKIP_VERIFY=false # true to skip TLS verification
1249
1288
 
1250
1289
  #### Security Options
1251
1290
  ```bash
1252
- // Webhook configuration
1253
- export WEBHOOK_TLS_SKIP_VERIFY=false # true to skip TLS verification
1291
+ // Webhook TLS (public destinations only; private/metadata URLs are always blocked)
1292
+ export SECURITY_OPTIONS_WEBHOOK_TLS_SKIP_VERIFY=false # true for self-signed public webhook hosts
1254
1293
  // Login mode control (see Login Mode section below)
1255
1294
  export SECURITY_OPTIONS_LOGIN_MODE=partner # partner, promiscuous, or p2p
1256
1295
  // Security thresholds
@@ -1487,26 +1526,40 @@ performanceService.recordMetric('user_action', 1, {
1487
1526
  })
1488
1527
  ```
1489
1528
 
1490
- ## Webhooks
1491
-
1492
- ### Overview
1529
+ ## Webhooks
1493
1530
 
1494
- The SDK supports sending webhooks to multiple endpoints for important events. Webhook URLs are configured in the RODiT token metadata.
1531
+ ### Overview
1532
+
1533
+ The SDK supports sending webhooks to multiple endpoints for important events. Webhook URLs come from the **peer JWT** claim `rodit_webhookurl` (from that peer’s RODiT `webhook_url` metadata).
1495
1534
 
1496
1535
  **Key Features:**
1497
1536
  - **Custom Endpoints** - Send webhooks to any endpoint path (e.g., `/hooks/wake`, `/hooks/agent`, `/webhook`)
1498
- - **Non-blocking** - Webhooks sent asynchronously without blocking the main response
1499
- - **Error Resilient** - Webhook failures don't affect the main operation
1537
+ - **Ed25519 signed** - Payload + timestamp signed; receivers verify via `X-Signature` / signer identity headers
1538
+ - **Outbound SSRF controls (9.15+)** - Before `fetch`, reject userinfo and private / loopback / link-local / ULA / CGNAT / cloud-metadata hostnames and resolved A/AAAA; enforce peer `rodit_webhookcidr` when set; resolve-once and pin DNS for the request
1539
+ - **Self-signed TLS (public only)** - `SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY=true` still allowed for **public** destinations
1500
1540
 
1501
- Webhooks are configured in your RODiT token:
1541
+ Peer RODiT metadata:
1502
1542
 
1503
1543
  ```json
1504
1544
  {
1505
- "webhook_url": "https:
1506
- "webhook_cidr": "0.0.0.0/0"
1545
+ "webhook_url": "hooks.example.com",
1546
+ "webhook_cidr": "0.0.0.0/0"
1507
1547
  }
1508
1548
  ```
1509
1549
 
1550
+ - `webhook_url` — host (optional path); SDK POSTs `https://{webhook_url}{endpoint}`
1551
+ - `webhook_cidr` — allowlist for the **resolved** destination IP (`0.0.0.0/0` / empty = any public IP still subject to the SSRF blocklist)
1552
+
1553
+ Outbound rejection codes (returned as `{ isValid: false, error: { code, message, requestId } }`):
1554
+
1555
+ | Code | Meaning |
1556
+ |------|---------|
1557
+ | `WEBHOOK_URL_MISSING` | No `rodit_webhookurl` on `req.user` |
1558
+ | `WEBHOOK_URL_INVALID` | Bad URL / userinfo / scheme |
1559
+ | `WEBHOOK_URL_BLOCKED` | Private, loopback, or metadata target |
1560
+ | `WEBHOOK_URL_UNRESOLVABLE` | DNS lookup failed |
1561
+ | `WEBHOOK_CIDR_DENIED` | Resolved IP outside peer `webhook_cidr` |
1562
+
1510
1563
  ### Sending Webhooks to Default Endpoint
1511
1564
 
1512
1565
  Send webhooks to the default `/webhook` endpoint:
@@ -2079,16 +2132,24 @@ Handle Express login requests from clients. Validates RODiT credentials and issu
2079
2132
  app.post('/api/login', (req, res) => roditClient.login_client(req, res))
2080
2133
  ```
2081
2134
 
2082
- **Request Body:** `login_client` accepts `accountid`, `timestamp`, and `base64url_signature`.
2135
+ **Request Body:** `accountid` or `roditid` (exactly one non-empty), `timestamp` (and/or `timestamp_iso`), and `base64url_signature` (or `roditid_base64url_signature`).
2136
+
2137
+ **On success:**
2138
+ - Sets `req.authenticatedRoditId` to the **verified** peer token id (`peer_rodit.token_id`) — do not use `req.body.roditid` for app hooks
2139
+ - JSON body includes the same value as `roditid` (in addition to `jwt_token` / `requestId`)
2140
+ - Creates a server session keyed by that peer id; issues JWT (`New-Token` header + body)
2083
2141
 
2084
2142
  **Response:**
2085
2143
  ```javascript
2086
2144
  {
2087
2145
  jwt_token: '<jwt-token>',
2088
- requestId: '01HQXYZ...'
2146
+ requestId: '01HQXYZ...',
2147
+ roditid: '<verified-peer-token-id>'
2089
2148
  }
2090
2149
  ```
2091
2150
 
2151
+ `login_client_withnep413` returns the same success shape (`jwt_token`, `requestId`, `roditid`) and also sets `req.authenticatedRoditId`.
2152
+
2092
2153
  ##### logout_client(req, res)
2093
2154
 
2094
2155
  Handle Express logout requests. Closes session and invalidates JWT token.
@@ -2275,6 +2336,23 @@ app.use(limiter)
2275
2336
 
2276
2337
  **Returns:** Express middleware function
2277
2338
 
2339
+ ##### getClaimRateLimitMiddleware()
2340
+
2341
+ Get the middleware factory that enforces per-route limits from
2342
+ `req.rateLimit` (populated by `validatepermissions` / `authorize`).
2343
+
2344
+ Place **after** authentication and permission validation. Skips when
2345
+ `req.rateLimit` is missing or `unlimited: true`. Default window is
2346
+ `req.rateLimit.timeWindow` seconds (60). Optional `{ store }` Map-like
2347
+ override for multi-instance deployments.
2348
+
2349
+ ```javascript
2350
+ const enforceRateLimitFromClaims = roditClient.getClaimRateLimitMiddleware()
2351
+ app.use('/api', authenticate, authorize, enforceRateLimitFromClaims())
2352
+ ```
2353
+
2354
+ **Returns:** Middleware factory `(options?) => Express middleware`
2355
+
2278
2356
  ##### getPerformanceService()
2279
2357
 
2280
2358
  Get the performance tracking service.
@@ -2310,7 +2388,12 @@ const webhookHandler = roditClient.getWebhookHandler()
2310
2388
 
2311
2389
  ##### send_webhook(payload, req)
2312
2390
 
2313
- Send a webhook notification.
2391
+ Send a webhook notification to the peer URL in `req.user.rodit_webhookurl`.
2392
+
2393
+ Outbound delivery rejects private / loopback / metadata destinations (and
2394
+ resolved addresses), enforces peer JWT `rodit_webhookcidr` when set, and pins
2395
+ DNS for the request. `SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY` still applies
2396
+ to **public** self-signed endpoints only.
2314
2397
 
2315
2398
  ```javascript
2316
2399
  const result = await roditClient.send_webhook({
@@ -2318,16 +2401,23 @@ const result = await roditClient.send_webhook({
2318
2401
  data: { userId: '123', action: 'login' },
2319
2402
  isError: false
2320
2403
  }, req)
2404
+
2405
+ if (!result.isValid) {
2406
+ // e.g. WEBHOOK_URL_BLOCKED, WEBHOOK_CIDR_DENIED, WEBHOOK_SEND_ERROR
2407
+ logger.warn('Webhook not delivered', result.error)
2408
+ }
2321
2409
  ```
2322
2410
 
2411
+ Aliases: `sendWebhook(payload, req)`, `sendWebhookToEndpoint(payload, endpoint, req)`.
2412
+
2323
2413
  **Parameters:**
2324
2414
  - `payload` (Object): Webhook payload
2325
2415
  - `event` (string): Event name
2326
2416
  - `data` (Object): Event data
2327
2417
  - `isError` (boolean): Whether this is an error event
2328
- - `req` (Object): Express request object (optional)
2418
+ - `req` (Object): Express request with authenticated `req.user` (peer JWT claims)
2329
2419
 
2330
- **Returns:** `Promise<Object>` - `{ success: boolean, ... }`
2420
+ **Returns:** `Promise<Object>` — success `{ isValid: true, message, requestId, duration }` or `{ isValid: false, error: { code, message, requestId } }`
2331
2421
 
2332
2422
  ### Exported Components
2333
2423
 
@@ -2363,6 +2453,7 @@ const {
2363
2453
  versioningMiddleware, // API versioning
2364
2454
  loggingmw, // Logging middleware
2365
2455
  ratelimitmw, // Rate limiting middleware
2456
+ enforceRateLimitFromClaims, // Consumes req.rateLimit from validatepermissions
2366
2457
  versionManager, // Version manager
2367
2458
  VersionManager, // Version manager class
2368
2459
  nearorg_rpc_timestamp // Blockchain RPC timestamp function
@@ -2393,8 +2484,8 @@ When you call `roditClient.getConfigOwnRodit()`, you get access to these metadat
2393
2484
  | `serviceprovider_signature` | string | Cryptographic signature for verification |
2394
2485
  | `subjectuniqueidentifier_url` | string | Primary API service endpoint |
2395
2486
  | `userselected_dn` | string | User-selected display name |
2396
- | `webhook_cidr` | string | Allowed IP ranges for webhooks |
2397
- | `webhook_url` | string | Webhook endpoint URL |
2487
+ | `webhook_cidr` | string | Allowlist CIDR(s) for outbound webhook resolved IPs (`0.0.0.0/0` = any public IP; private ranges are still blocked by SSRF controls) |
2488
+ | `webhook_url` | string | Peer webhook host used as `rodit_webhookurl` in issued JWTs |
2398
2489
 
2399
2490
  ## Best Practices
2400
2491
 
package/index.js CHANGED
@@ -41,6 +41,7 @@ const { versioningMiddleware } = require('./lib/middleware/versioningmw');
41
41
  const { VersionManager } = require('./services/versionmanager');
42
42
  const loggingmw = require('./lib/middleware/loggingmw');
43
43
  const ratelimitmw = require('./lib/middleware/ratelimitmw');
44
+ const enforceRateLimitFromClaims = require('./lib/middleware/enforceratelimitmw');
44
45
  const utils = require('./services/utils');
45
46
  const config = require('./services/configsdk');
46
47
  const performanceService = require('./services/performanceservice');
@@ -404,6 +405,14 @@ class RoditClient {
404
405
  return ratelimitmw;
405
406
  }
406
407
 
408
+ /**
409
+ * Get middleware that enforces `req.rateLimit` from validatepermissions.
410
+ * @returns {Function} Middleware factory: enforceRateLimitFromClaims(options?)
411
+ */
412
+ getClaimRateLimitMiddleware() {
413
+ return enforceRateLimitFromClaims;
414
+ }
415
+
407
416
  /**
408
417
  * Get utilities
409
418
  * @returns {Object} Utilities object
@@ -1136,9 +1145,6 @@ class RoditClient {
1136
1145
  case 'RODIT_NOT_LIVE':
1137
1146
  errorMessage += '\n→ [CLIENT REJECTED] Your RODiT is expired or not yet valid. Check the validity period.';
1138
1147
  break;
1139
- case 'RODIT_REVOKED':
1140
- errorMessage += '\n→ [CLIENT REJECTED] Your RODiT has been revoked and is no longer valid.';
1141
- break;
1142
1148
  case 'SMART_CONTRACT_NOT_TRUSTED':
1143
1149
  errorMessage += '\n→ [CLIENT REJECTED] The smart contract that issued your RODiT is not trusted by this server.';
1144
1150
  break;
@@ -1151,9 +1157,6 @@ class RoditClient {
1151
1157
  case 'SERVER_RODIT_NOT_LIVE':
1152
1158
  errorMessage += '\n→ [SERVER REJECTED] The server\'s RODiT is expired or not yet valid. Contact the server administrator.';
1153
1159
  break;
1154
- case 'SERVER_RODIT_REVOKED':
1155
- errorMessage += '\n→ [SERVER REJECTED] The server\'s RODiT has been revoked. Contact the server administrator.';
1156
- break;
1157
1160
  case 'SERVER_SMART_CONTRACT_NOT_TRUSTED':
1158
1161
  errorMessage += '\n→ [SERVER REJECTED] The server\'s issuing smart contract is not trusted by your client. Update your trust configuration.';
1159
1162
  break;
@@ -1950,6 +1953,7 @@ module.exports = {
1950
1953
  versioningMiddleware,
1951
1954
  loggingmw,
1952
1955
  ratelimitmw,
1956
+ enforceRateLimitFromClaims,
1953
1957
  versionManager,
1954
1958
  VersionManager,
1955
1959
  // Blockchain service functions
@@ -993,32 +993,6 @@ async function verify_rodit_ownership(
993
993
  };
994
994
  }
995
995
 
996
- const activeStart = Date.now();
997
- const isActive = await verify_rodit_isactive(
998
- peer_rodit.token_id,
999
- config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url
1000
- );
1001
- const activeDuration = Date.now() - activeStart;
1002
-
1003
- logger.debug("Active verification completed", {
1004
- requestId,
1005
- activeDuration,
1006
- isActive,
1007
- });
1008
-
1009
- if (!isActive) {
1010
- logger.warn("RODiT active verification failed", {
1011
- requestId,
1012
- roditId: peerroditid,
1013
- });
1014
- return {
1015
- peer_rodit,
1016
- goodrodit: false,
1017
- failureReason: "RODIT_REVOKED",
1018
- failureMessage: "RODiT has been revoked"
1019
- };
1020
- }
1021
-
1022
996
  const trustedStart = Date.now();
1023
997
  const isTrusted = await verify_rodit_istrusted_issuingsmartcontract(
1024
998
  config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url
@@ -1241,119 +1215,6 @@ async function verify_rodit_ownership(
1241
1215
  }
1242
1216
  }
1243
1217
 
1244
- async function verify_rodit_isactive(tokenId, ownsubjectuniqueidentifier_url) {
1245
- const requestId = ulid();
1246
- const startTime = Date.now();
1247
-
1248
- // WHILE DEBUGGING TEMPORARY FIX DO NOT REMOVE THIS LINE EVER WITHOUT PERMISSION
1249
- return true;
1250
-
1251
- logger.debug("Checking RODiT activity status", {
1252
- component: "RoditAuth",
1253
- method: "verify_rodit_isactive",
1254
- requestId,
1255
- tokenId,
1256
- subjectUrl: ownsubjectuniqueidentifier_url,
1257
- });
1258
-
1259
- const domainandextensionRegex =
1260
- /(?:https?:\/\/)?(?:www\.)?([a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)/i;
1261
-
1262
- const match = ownsubjectuniqueidentifier_url.match(domainandextensionRegex);
1263
-
1264
- if (match) {
1265
- const domainandextension = match[1];
1266
- const revokingDnsEntry = `${tokenId}.revoked.${domainandextension}`;
1267
-
1268
- logger.debug("Checking DNS revocation entry", {
1269
- requestId,
1270
- domain: domainandextension,
1271
- revokingDnsEntry,
1272
- });
1273
-
1274
- try {
1275
- const dnsStart = Date.now();
1276
- const resolver = new Resolver();
1277
- await resolver.resolveTxt(revokingDnsEntry);
1278
- const dnsDuration = Date.now() - dnsStart;
1279
- const totalDuration = Date.now() - startTime;
1280
-
1281
- logger.info("RODiT revocation found", {
1282
- component: "AuthServices",
1283
- method: "verify_rodit_isactive",
1284
- requestId,
1285
- duration: totalDuration,
1286
- dnsDuration,
1287
- tokenId,
1288
- domain: domainandextension,
1289
- revokingDnsEntry,
1290
- isActive: false,
1291
- });
1292
-
1293
- // Add metrics for revoked tokens
1294
- logger.metric &&
1295
- logger.metric("rodit_revocation_checks", totalDuration, {
1296
- result: "revoked",
1297
- token_id: tokenId,
1298
- });
1299
-
1300
- return false;
1301
- } catch (error) {
1302
- // DNS error usually means no revocation entry found, which is good
1303
- const dnsDuration = Date.now() - dnsStart || 0;
1304
- const totalDuration = Date.now() - startTime;
1305
-
1306
- logger.debug("No revocation found for RODiT", {
1307
- requestId,
1308
- dnsDuration,
1309
- tokenId,
1310
- error: error.code,
1311
- });
1312
-
1313
- logger.info("RODiT is active", {
1314
- component: "AuthServices",
1315
- method: "verify_rodit_isactive",
1316
- requestId,
1317
- duration: totalDuration,
1318
- dnsDuration,
1319
- tokenId,
1320
- domain: domainandextension,
1321
- isActive: true,
1322
- });
1323
-
1324
- // Add metrics for active tokens
1325
- logger.metric &&
1326
- logger.metric("rodit_revocation_checks", totalDuration, {
1327
- result: "active",
1328
- token_id: tokenId,
1329
- });
1330
-
1331
- return true;
1332
- }
1333
- } else {
1334
- const duration = Date.now() - startTime;
1335
-
1336
- logger.warn("Unable to parse domain from URL", {
1337
- component: "AuthServices",
1338
- method: "verify_rodit_isactive",
1339
- requestId,
1340
- duration,
1341
- tokenId,
1342
- subjectUrl: ownsubjectuniqueidentifier_url,
1343
- });
1344
-
1345
- // Add metrics for parsing errors
1346
- logger.metric &&
1347
- logger.metric("rodit_revocation_checks", duration, {
1348
- result: "parse_error",
1349
- token_id: tokenId,
1350
- });
1351
-
1352
- // Default to allowing the token if domain parsing fails
1353
- return true;
1354
- }
1355
- }
1356
-
1357
1218
  async function verify_rodit_istrusted_issuingsmartcontract(
1358
1219
  ownsubjectuniqueidentifier_url
1359
1220
  ) {
@@ -1963,7 +1824,6 @@ module.exports = {
1963
1824
  resolve_peer_rodit_for_login,
1964
1825
  verify_peer_rodit,
1965
1826
  validateTimestamp,
1966
- verify_rodit_isactive,
1967
1827
  verify_rodit_isamatch,
1968
1828
  verify_rodit_islive,
1969
1829
  verify_rodit_istrusted_issuingsmartcontract,
@@ -2085,7 +2085,7 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
2085
2085
  // Starting verification with more detailed logging
2086
2086
  logger.debug("Starting verification checks", {
2087
2087
  requestId,
2088
- checks: ["match", "live", "active", "trusted"],
2088
+ checks: ["match", "live", "trusted"],
2089
2089
  serviceProviderId: config_own_rodit.own_rodit.metadata.serviceprovider_id,
2090
2090
  });
2091
2091
 
@@ -2093,7 +2093,6 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
2093
2093
  const {
2094
2094
  verify_rodit_isamatch,
2095
2095
  verify_rodit_islive,
2096
- verify_rodit_isactive,
2097
2096
  verify_rodit_istrusted_issuingsmartcontract
2098
2097
  } = require("./authentication");
2099
2098
 
@@ -2188,49 +2187,6 @@ async function thorough_validate_jwt_token_be(token, requestId = ulid()) {
2188
2187
  };
2189
2188
  }
2190
2189
 
2191
- // Perform active verification
2192
- const activeStart = performance.now();
2193
- const isActive = await verify_rodit_isactive(
2194
- peer_rodit.token_id,
2195
- config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url
2196
- );
2197
- const activeDuration = performance.now() - activeStart;
2198
-
2199
- logger.debug("Active verification completed", {
2200
- requestId,
2201
- activeDuration,
2202
- isActive,
2203
- tokenId: peer_rodit.token_id,
2204
- url: config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url,
2205
- });
2206
-
2207
- if (!isActive) {
2208
- logger.warn("RODiT active verification failed", {
2209
- component: "JwtAuth",
2210
- method: "thorough_validate_jwt_token_be",
2211
- requestId,
2212
- duration: performance.now() - startTime,
2213
- tokenId: peer_rodit.token_id,
2214
- url: config_own_rodit.own_rodit.metadata.subjectuniqueidentifier_url,
2215
- });
2216
-
2217
- // Add metrics for failed active verification
2218
- logger.metric &&
2219
- logger.metric("jwt_thorough_validation", performance.now() - startTime, {
2220
- result: "active_failed",
2221
- token_jti: token.jti || "unknown",
2222
- peer_rodit_id: peer_rodit.token_id,
2223
- });
2224
-
2225
- return {
2226
- isValid: false,
2227
- notAfter: null,
2228
- error: "RODiT active verification failed",
2229
- errorCode: "SERVER_RODIT_REVOKED",
2230
- errorMessage: "Server's RODiT has been revoked"
2231
- };
2232
- }
2233
-
2234
2190
  // Perform trusted verification
2235
2191
  const trustedStart = performance.now();
2236
2192
  const isTrusted = await verify_rodit_istrusted_issuingsmartcontract(
@@ -586,9 +586,14 @@ async function login_client(req, res) {
586
586
  // Set the jwt_token in the response header
587
587
  res.setHeader('New-Token', jwt_token);
588
588
 
589
+ // Server-derived peer identity (verified RODiT only — do not trust client body).
590
+ const authenticatedRoditId = peer_rodit.token_id;
591
+ req.authenticatedRoditId = authenticatedRoditId;
592
+
589
593
  return res.json({
590
594
  jwt_token,
591
- requestId
595
+ requestId,
596
+ roditid: authenticatedRoditId
592
597
  });
593
598
  } catch (error) {
594
599
  const duration = Date.now() - startTime;
@@ -1453,13 +1458,19 @@ async function login_client(req, res) {
1453
1458
  requestId,
1454
1459
  response: {
1455
1460
  requestId: requestId,
1456
- jwt_token_length: jwt_token ? jwt_token.length : 0
1461
+ jwt_token_length: jwt_token ? jwt_token.length : 0,
1462
+ roditid: peer_rodit.token_id
1457
1463
  }
1458
1464
  });
1459
1465
 
1466
+ // Server-derived peer identity (verified RODiT only — do not trust client body).
1467
+ const authenticatedRoditId = peer_rodit.token_id;
1468
+ req.authenticatedRoditId = authenticatedRoditId;
1469
+
1460
1470
  return res.json({
1461
1471
  jwt_token,
1462
1472
  requestId,
1473
+ roditid: authenticatedRoditId,
1463
1474
  });
1464
1475
  } catch (error) {
1465
1476
  const duration = Date.now() - startTime;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Enforce per-route rate limits from `req.rateLimit` (set by validatepermissions).
3
+ * Copyright (c) 2026 Discernible IO. All rights reserved.
4
+ */
5
+
6
+ const { ulid } = require("ulid");
7
+ const logger = require("../../services/logger");
8
+ const { createLogContext } = logger;
9
+ const { sendError } = require("../../services/error-response");
10
+
11
+ /**
12
+ * In-memory fixed-window counters keyed by subject + path + operation.
13
+ * Suitable for single-process apps; replace/store externally for multi-instance.
14
+ */
15
+ const windows = new Map();
16
+
17
+ function cleanupExpired(now) {
18
+ if (windows.size < 1000) return;
19
+ for (const [key, entry] of windows) {
20
+ if (entry.resetAt <= now) {
21
+ windows.delete(key);
22
+ }
23
+ }
24
+ }
25
+
26
+ function rateLimitSubject(req) {
27
+ if (req.authenticatedRoditId) return String(req.authenticatedRoditId);
28
+ if (req.user) {
29
+ if (req.user.sub) return String(req.user.sub);
30
+ if (req.user.rodit_id) return String(req.user.rodit_id);
31
+ }
32
+ return req.ip || "anonymous";
33
+ }
34
+
35
+ /**
36
+ * Middleware factory that consumes `req.rateLimit` metadata produced by
37
+ * `validatepermissions`. Place after permission validation on protected routes.
38
+ *
39
+ * @param {Object} [options]
40
+ * @param {Map} [options.store] Optional external store (Map-like get/set)
41
+ * @returns {Function} Express middleware
42
+ *
43
+ * @example
44
+ * app.use('/api', authenticate_apicall, validatepermissions, enforceRateLimitFromClaims());
45
+ */
46
+ function enforceRateLimitFromClaims(options = {}) {
47
+ const store = options.store || windows;
48
+
49
+ return function enforceRateLimitFromClaimsMiddleware(req, res, next) {
50
+ const requestId = req.requestId || ulid();
51
+ const rate = req.rateLimit;
52
+
53
+ if (!rate || rate.unlimited === true) {
54
+ return next();
55
+ }
56
+
57
+ const limit = Number(rate.value);
58
+ if (!Number.isFinite(limit) || limit <= 0) {
59
+ // Missing / unparsable claim → do not invent a limit
60
+ return next();
61
+ }
62
+
63
+ const timeWindowSeconds =
64
+ Number.isFinite(Number(rate.timeWindow)) && Number(rate.timeWindow) > 0
65
+ ? Number(rate.timeWindow)
66
+ : 60;
67
+ const now = Date.now();
68
+ const subject = rateLimitSubject(req);
69
+ const pathKey = rate.path || req.path || "";
70
+ const operation = rate.operation || "";
71
+ const key = `${subject}|${pathKey}|${operation}`;
72
+
73
+ cleanupExpired(now);
74
+
75
+ let entry = store.get(key);
76
+ if (!entry || entry.resetAt <= now) {
77
+ entry = {
78
+ count: 0,
79
+ resetAt: now + timeWindowSeconds * 1000,
80
+ limit,
81
+ };
82
+ }
83
+
84
+ entry.count += 1;
85
+ store.set(key, entry);
86
+
87
+ const remaining = Math.max(0, limit - entry.count);
88
+ const resetSeconds = Math.max(1, Math.ceil((entry.resetAt - now) / 1000));
89
+
90
+ res.setHeader("X-RateLimit-Limit", String(limit));
91
+ res.setHeader("X-RateLimit-Remaining", String(remaining));
92
+ res.setHeader("X-RateLimit-Reset", String(resetSeconds));
93
+
94
+ if (entry.count > limit) {
95
+ const baseContext = createLogContext("EnforceRateLimit", "claimExceeded", {
96
+ requestId,
97
+ subject,
98
+ path: pathKey,
99
+ operation,
100
+ limit,
101
+ count: entry.count,
102
+ timeWindowSeconds,
103
+ });
104
+ logger.warnWithContext("Per-route claim rate limit exceeded", {
105
+ ...baseContext,
106
+ result: "blocked",
107
+ });
108
+ logger.metric &&
109
+ logger.metric("rate_limit_operations", 0, {
110
+ operation: "claim_limit_exceeded",
111
+ path: pathKey,
112
+ result: "blocked",
113
+ });
114
+
115
+ return sendError(res, {
116
+ statusCode: 429,
117
+ requestId,
118
+ code: "RATE_LIMIT_EXCEEDED",
119
+ message: "Rate limit exceeded for this permissioned route",
120
+ details: {
121
+ limit,
122
+ timeWindowSeconds,
123
+ operation: operation || undefined,
124
+ path: pathKey || undefined,
125
+ },
126
+ });
127
+ }
128
+
129
+ return next();
130
+ };
131
+ }
132
+
133
+ module.exports = enforceRateLimitFromClaims;
134
+ module.exports.enforceRateLimitFromClaims = enforceRateLimitFromClaims;
135
+ module.exports._windows = windows;
@@ -416,7 +416,7 @@ async function validatepermissions(req, res, next) {
416
416
  req.commentsRate = result.commentsRate;
417
417
  }
418
418
 
419
- // Make rate limit information available for future implementation
419
+ // Make rate limit information available for enforceRateLimitFromClaims()
420
420
  req.rateLimit = {
421
421
  value: result.rateLimit,
422
422
  unlimited: result.unlimited === true,
@@ -8,6 +8,8 @@
8
8
 
9
9
  const crypto = require("crypto");
10
10
  const https = require("https");
11
+ const dns = require("dns").promises;
12
+ const net = require("net");
11
13
  const { Agent } = require("undici");
12
14
  const config = require('../../services/configsdk');
13
15
  const { isStrictEnvironment, getNodeEnv } = require('../../services/env');
@@ -20,6 +22,304 @@ const stateManager = require("../blockchain/statemanager");
20
22
  const { sessionManager } = require("../auth/sessionmanager");
21
23
  const { authenticate_webhook } = require("../auth/authentication");
22
24
 
25
+ // ---------------------------------------------------------------------------
26
+ // Outbound webhook URL SSRF / CIDR / DNS-pin helpers
27
+ // Policy aligned with SLC `src/game/url-join-identity.js`.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function webhookUrlError(code, message) {
31
+ const err = new Error(message);
32
+ err.code = code;
33
+ return err;
34
+ }
35
+
36
+ /**
37
+ * Reject loopback / RFC1918 / link-local / ULA / CGNAT / cloud-metadata destinations.
38
+ * Self-signed TLS for public webhook hosts remains allowed (WEBHOOK_TLS_SKIP_VERIFY).
39
+ */
40
+ function isBlockedIpAddress(ip) {
41
+ const version = net.isIP(ip);
42
+ if (version === 4) {
43
+ const parts = ip.split(".").map((p) => Number(p));
44
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
45
+ return true;
46
+ }
47
+ const [a, b] = parts;
48
+ if (a === 0) return true; // 0.0.0.0/8
49
+ if (a === 10) return true; // 10.0.0.0/8
50
+ if (a === 127) return true; // 127.0.0.0/8
51
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local / metadata
52
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
53
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
54
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
55
+ if (a >= 224) return true; // multicast / reserved
56
+ return false;
57
+ }
58
+ if (version === 6) {
59
+ const normalized = ip.toLowerCase();
60
+ if (normalized === "::" || normalized === "::1") return true;
61
+ const mapped = normalized.match(/:ffff:(\d+\.\d+\.\d+\.\d+)$/i);
62
+ if (mapped) return isBlockedIpAddress(mapped[1]);
63
+ const first = normalized.split(":")[0] || "";
64
+ const firstVal = Number.parseInt(first, 16);
65
+ if (Number.isFinite(firstVal)) {
66
+ if ((firstVal & 0xffc0) === 0xfe80) return true; // fe80::/10
67
+ if ((firstVal & 0xfe00) === 0xfc00) return true; // fc00::/7
68
+ if ((firstVal & 0xff00) === 0xff00) return true; // ff00::/8
69
+ }
70
+ return false;
71
+ }
72
+ return true;
73
+ }
74
+
75
+ function isBlockedHostname(hostname) {
76
+ const host = String(hostname || "")
77
+ .toLowerCase()
78
+ .replace(/\.$/, "")
79
+ .replace(/^\[|\]$/g, "");
80
+ if (!host) return true;
81
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
82
+ if (host === "metadata.google.internal" || host.endsWith(".metadata.google.internal")) {
83
+ return true;
84
+ }
85
+ if (host === "metadata" || host.endsWith(".internal")) return true;
86
+ if (net.isIP(host)) return isBlockedIpAddress(host);
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * Synchronous URL policy checks (scheme, length, userinfo, blocked hostname literals).
92
+ * @param {string} raw Absolute http(s) URL
93
+ * @returns {URL}
94
+ */
95
+ function validateOutboundWebhookUrl(raw) {
96
+ if (typeof raw !== "string" || !raw.trim()) {
97
+ throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL is required");
98
+ }
99
+ const trimmed = raw.trim();
100
+ if (trimmed.length > 2048) {
101
+ throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL is too long");
102
+ }
103
+ let parsed;
104
+ try {
105
+ parsed = new URL(trimmed);
106
+ } catch {
107
+ throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL must be an absolute URL");
108
+ }
109
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
110
+ throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL must use http or https");
111
+ }
112
+ if (parsed.username || parsed.password) {
113
+ throw webhookUrlError("WEBHOOK_URL_INVALID", "Webhook URL must not contain userinfo");
114
+ }
115
+ if (isBlockedHostname(parsed.hostname)) {
116
+ throw webhookUrlError(
117
+ "WEBHOOK_URL_BLOCKED",
118
+ "Webhook URL must not target a private, loopback, or metadata host"
119
+ );
120
+ }
121
+ return parsed;
122
+ }
123
+
124
+ function ipv4ToInt(ip) {
125
+ return ip.split(".").reduce((acc, oct) => ((acc << 8) + Number(oct)) >>> 0, 0) >>> 0;
126
+ }
127
+
128
+ function parseCidrList(cidrClaim) {
129
+ if (cidrClaim == null) return [];
130
+ const raw = String(cidrClaim).trim();
131
+ if (!raw) return [];
132
+ return raw
133
+ .split(/[\s,;]+/)
134
+ .map((part) => part.trim())
135
+ .filter(Boolean);
136
+ }
137
+
138
+ function isUnrestrictedCidr(cidr) {
139
+ const value = String(cidr || "").trim().toLowerCase();
140
+ return value === "0.0.0.0/0" || value === "::/0" || value === "*";
141
+ }
142
+
143
+ function isIpInCidr(ip, cidr) {
144
+ const trimmed = String(cidr || "").trim();
145
+ if (!trimmed || isUnrestrictedCidr(trimmed)) return true;
146
+
147
+ const slash = trimmed.indexOf("/");
148
+ if (slash < 0) return false;
149
+ const base = trimmed.slice(0, slash);
150
+ const prefix = Number(trimmed.slice(slash + 1));
151
+ const ipVer = net.isIP(ip);
152
+ const baseVer = net.isIP(base);
153
+ if (!ipVer || !baseVer || ipVer !== baseVer || !Number.isInteger(prefix)) {
154
+ return false;
155
+ }
156
+
157
+ if (ipVer === 4) {
158
+ if (prefix < 0 || prefix > 32) return false;
159
+ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
160
+ return (ipv4ToInt(ip) & mask) === (ipv4ToInt(base) & mask);
161
+ }
162
+
163
+ // IPv6: compare prefix bits of normalized 128-bit forms
164
+ if (prefix < 0 || prefix > 128) return false;
165
+ const toBytes = (addr) => {
166
+ const buf = Buffer.alloc(16);
167
+ const full = expandIpv6(addr);
168
+ for (let i = 0; i < 8; i++) {
169
+ const hextet = Number.parseInt(full[i], 16);
170
+ buf.writeUInt16BE(hextet, i * 2);
171
+ }
172
+ return buf;
173
+ };
174
+
175
+ try {
176
+ const ipBuf = toBytes(ip);
177
+ const baseBuf = toBytes(base);
178
+ const fullBytes = Math.floor(prefix / 8);
179
+ const remBits = prefix % 8;
180
+ for (let i = 0; i < fullBytes; i++) {
181
+ if (ipBuf[i] !== baseBuf[i]) return false;
182
+ }
183
+ if (remBits === 0) return true;
184
+ const mask = 0xff << (8 - remBits);
185
+ return (ipBuf[fullBytes] & mask) === (baseBuf[fullBytes] & mask);
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ function expandIpv6(addr) {
192
+ const lower = String(addr).toLowerCase().replace(/^\[|\]$/g, "");
193
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
194
+ if (mapped) {
195
+ const parts = mapped[1].split(".").map(Number);
196
+ const hi = ((parts[0] << 8) | parts[1]).toString(16);
197
+ const lo = ((parts[2] << 8) | parts[3]).toString(16);
198
+ return ["0", "0", "0", "0", "0", "ffff", hi, lo];
199
+ }
200
+ const [head, tail] = lower.split("::");
201
+ const headParts = head ? head.split(":") : [];
202
+ const tailParts = tail ? tail.split(":") : [];
203
+ const missing = 8 - (headParts.length + tailParts.length);
204
+ if (missing < 0) {
205
+ throw new Error("invalid ipv6");
206
+ }
207
+ const middle = Array(missing).fill("0");
208
+ const parts = [...headParts, ...middle, ...tailParts].map((p) => p || "0");
209
+ if (parts.length !== 8) {
210
+ throw new Error("invalid ipv6");
211
+ }
212
+ return parts;
213
+ }
214
+
215
+ function assertIpAllowedByWebhookCidr(ip, cidrClaim) {
216
+ const cidrs = parseCidrList(cidrClaim);
217
+ if (cidrs.length === 0 || cidrs.every(isUnrestrictedCidr)) {
218
+ return;
219
+ }
220
+ const allowed = cidrs.some((cidr) => isIpInCidr(ip, cidr));
221
+ if (!allowed) {
222
+ throw webhookUrlError(
223
+ "WEBHOOK_CIDR_DENIED",
224
+ "Resolved webhook address is outside the peer webhook_cidr allowlist"
225
+ );
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Resolve hostname once, reject blocked addresses, enforce webhook_cidr, and
231
+ * return a single pinned address for the request lifetime (DNS rebinding defense).
232
+ *
233
+ * @param {string|URL} url
234
+ * @param {string} [webhookCidr] JWT/peer `rodit_webhookcidr` / `webhook_cidr`
235
+ * @returns {Promise<{ url: URL, hostname: string, address: string, family: number }>}
236
+ */
237
+ async function resolveAndPinWebhookDestination(url, webhookCidr) {
238
+ const parsed = url instanceof URL ? url : validateOutboundWebhookUrl(url);
239
+ const hostname = String(parsed.hostname || "").replace(/^\[|\]$/g, "");
240
+
241
+ if (net.isIP(hostname)) {
242
+ if (isBlockedIpAddress(hostname)) {
243
+ throw webhookUrlError(
244
+ "WEBHOOK_URL_BLOCKED",
245
+ "Webhook URL must not target a private, loopback, or metadata host"
246
+ );
247
+ }
248
+ assertIpAllowedByWebhookCidr(hostname, webhookCidr);
249
+ return {
250
+ url: parsed,
251
+ hostname,
252
+ address: hostname,
253
+ family: net.isIP(hostname),
254
+ };
255
+ }
256
+
257
+ let records;
258
+ try {
259
+ records = await dns.lookup(hostname, { all: true, verbatim: true });
260
+ } catch {
261
+ throw webhookUrlError(
262
+ "WEBHOOK_URL_UNRESOLVABLE",
263
+ "Webhook URL hostname could not be resolved"
264
+ );
265
+ }
266
+ if (!records.length) {
267
+ throw webhookUrlError(
268
+ "WEBHOOK_URL_UNRESOLVABLE",
269
+ "Webhook URL hostname could not be resolved"
270
+ );
271
+ }
272
+
273
+ const candidates = records.filter((record) => !isBlockedIpAddress(record.address));
274
+ if (!candidates.length) {
275
+ throw webhookUrlError(
276
+ "WEBHOOK_URL_BLOCKED",
277
+ "Webhook URL must not resolve to a private, loopback, or metadata address"
278
+ );
279
+ }
280
+
281
+ const cidrs = parseCidrList(webhookCidr);
282
+ const restrictCidr = cidrs.length > 0 && !cidrs.every(isUnrestrictedCidr);
283
+ const allowed = restrictCidr
284
+ ? candidates.filter((record) => cidrs.some((cidr) => isIpInCidr(record.address, cidr)))
285
+ : candidates;
286
+
287
+ if (!allowed.length) {
288
+ throw webhookUrlError(
289
+ "WEBHOOK_CIDR_DENIED",
290
+ "Resolved webhook address is outside the peer webhook_cidr allowlist"
291
+ );
292
+ }
293
+
294
+ // Prefer IPv4 when available for broader destination compatibility.
295
+ const pinned = allowed.find((r) => r.family === 4) || allowed[0];
296
+ return {
297
+ url: parsed,
298
+ hostname,
299
+ address: pinned.address,
300
+ family: pinned.family,
301
+ };
302
+ }
303
+
304
+ /**
305
+ * undici Agent that always returns the pinned address for DNS lookups
306
+ * (keeps Host / SNI as the original hostname).
307
+ */
308
+ function createPinnedDispatcher({ address, family, skipTlsVerify = false }) {
309
+ return new Agent({
310
+ connect: {
311
+ rejectUnauthorized: !skipTlsVerify,
312
+ lookup: (hostname, options, callback) => {
313
+ if (options && options.all) {
314
+ callback(null, [{ address, family }]);
315
+ return;
316
+ }
317
+ callback(null, address, family);
318
+ },
319
+ },
320
+ });
321
+ }
322
+
23
323
  // ---------------------------------------------------------------------------
24
324
  // Webhook identity + session helpers
25
325
  // ---------------------------------------------------------------------------
@@ -915,12 +1215,52 @@ async function resolveOutboundWebhookSessionId(options = {}, req = null) {
915
1215
  .replace(/\/+$/, "");
916
1216
  const normalizedEndpoint = `/${String(endpoint || "/webhook").replace(/^\/+/, "")}`;
917
1217
  const formattedWebhookUrl = `https://${cleanWebhookUrl}${normalizedEndpoint}`;
1218
+
1219
+ // Outbound SSRF controls: reject userinfo / private / metadata destinations,
1220
+ // enforce peer webhook_cidr, and pin DNS for the request lifetime.
1221
+ // WEBHOOK_TLS_SKIP_VERIFY remains allowed for public self-signed hosts.
1222
+ let pinnedDestination;
1223
+ try {
1224
+ validateOutboundWebhookUrl(formattedWebhookUrl);
1225
+ pinnedDestination = await resolveAndPinWebhookDestination(
1226
+ formattedWebhookUrl,
1227
+ req.user.rodit_webhookcidr
1228
+ );
1229
+ } catch (ssrfError) {
1230
+ const duration = Date.now() - startTime;
1231
+ const errorCode = ssrfError.code || "WEBHOOK_URL_BLOCKED";
1232
+ logger.warnWithContext("Outbound webhook URL rejected by SSRF controls", {
1233
+ ...baseContext,
1234
+ duration,
1235
+ formattedWebhookUrl,
1236
+ webhookCidr: req.user.rodit_webhookcidr || null,
1237
+ errorCode,
1238
+ error: ssrfError.message
1239
+ });
1240
+ logger.metric &&
1241
+ logger.metric("webhook_delivery_failures_total", 1, {
1242
+ component: "WebhookHandler",
1243
+ reason: errorCode,
1244
+ event,
1245
+ });
1246
+ return {
1247
+ isValid: false,
1248
+ error: {
1249
+ code: errorCode,
1250
+ message: ssrfError.message,
1251
+ requestId,
1252
+ },
1253
+ };
1254
+ }
918
1255
 
919
1256
  logger.debugWithContext("Webhook URL details", {
920
1257
  ...baseContext,
921
1258
  rawWebhookUrl: webhookUrl,
922
1259
  endpoint,
923
- formattedWebhookUrl
1260
+ formattedWebhookUrl,
1261
+ pinnedAddress: pinnedDestination.address,
1262
+ pinnedFamily: pinnedDestination.family,
1263
+ webhookCidr: req.user.rodit_webhookcidr || null
924
1264
  });
925
1265
 
926
1266
  const timestamp = Date.now();
@@ -1186,7 +1526,8 @@ async function resolveOutboundWebhookSessionId(options = {}, req = null) {
1186
1526
  // Configure HTTPS agent to skip TLS verification if configured
1187
1527
  // This is necessary when webhook destinations use self-signed certificates
1188
1528
  // Since mutual authentication via digital signatures is already in place,
1189
- // skipping TLS verification is safe in this context
1529
+ // skipping TLS verification is safe in this context (public destinations only;
1530
+ // private/metadata hosts are rejected above).
1190
1531
  const skipTlsVerify = config.has('SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY')
1191
1532
  ? String(config.get('SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY')).toLowerCase() === 'true'
1192
1533
  : false;
@@ -1196,27 +1537,37 @@ async function resolveOutboundWebhookSessionId(options = {}, req = null) {
1196
1537
  headers: headers,
1197
1538
  body: payload,
1198
1539
  };
1199
-
1540
+
1541
+ // Always pin DNS via undici lookup so a rebinding race cannot retarget
1542
+ // the TCP connection after the SSRF checks above.
1543
+ const undiciAgent = createPinnedDispatcher({
1544
+ address: pinnedDestination.address,
1545
+ family: pinnedDestination.family,
1546
+ skipTlsVerify,
1547
+ });
1548
+ fetchOptions.dispatcher = undiciAgent;
1549
+
1200
1550
  if (skipTlsVerify) {
1201
- // Create custom undici Agent that accepts self-signed certificates
1202
- // Node.js fetch uses undici under the hood and requires 'dispatcher' option
1203
- const undiciAgent = new Agent({
1204
- connect: {
1205
- rejectUnauthorized: false
1206
- }
1207
- });
1208
- fetchOptions.dispatcher = undiciAgent;
1209
-
1210
1551
  logger.debugWithContext("Webhook TLS verification disabled", {
1211
1552
  ...baseContext,
1212
1553
  skipTlsVerify: true,
1213
- reason: "SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY=true"
1554
+ reason: "SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY=true",
1555
+ pinnedAddress: pinnedDestination.address
1214
1556
  });
1215
1557
  }
1216
1558
 
1217
1559
  // Send webhook request
1218
1560
  const fetchStartTime = Date.now();
1219
- const response = await fetch(formattedWebhookUrl, fetchOptions);
1561
+ let response;
1562
+ try {
1563
+ response = await fetch(formattedWebhookUrl, fetchOptions);
1564
+ } finally {
1565
+ try {
1566
+ await undiciAgent.close();
1567
+ } catch (_closeError) {
1568
+ // ignore dispatcher close errors
1569
+ }
1570
+ }
1220
1571
  const fetchDuration = Date.now() - fetchStartTime;
1221
1572
 
1222
1573
  // Log fetch duration metrics
@@ -1748,6 +2099,11 @@ module.exports = {
1748
2099
  implicitAccountToBase64urlKey,
1749
2100
  base64urlKeyToImplicitAccount,
1750
2101
 
2102
+ // Outbound SSRF / CIDR / DNS-pin helpers
2103
+ validateOutboundWebhookUrl,
2104
+ resolveAndPinWebhookDestination,
2105
+ createPinnedDispatcher,
2106
+
1751
2107
  // Added exports from eventhandler.js
1752
2108
  WebhookEventHandler,
1753
2109
  TestConfigUpdateHandler,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rodit/rodit-auth-be",
3
- "version": "9.14.0",
3
+ "version": "9.16.0",
4
4
  "description": "RODiT-based authentication system for Express.js applications",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -46,8 +46,13 @@
46
46
  },
47
47
  "repository": {
48
48
  "type": "git",
49
- "url": "git+https://github.com/discernible-io/rodit-auth-be.git"
49
+ "url": "git+https://github.com/discernible-io/rodit-sdk.git",
50
+ "directory": "rodit-auth-be"
50
51
  },
52
+ "bugs": {
53
+ "url": "https://github.com/discernible-io/rodit-sdk/issues"
54
+ },
55
+ "homepage": "https://github.com/discernible-io/rodit-sdk/tree/main/rodit-auth-be#readme",
51
56
  "publishConfig": {
52
57
  "access": "public"
53
58
  },