@rodit/rodit-auth-be 9.14.0 → 9.15.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 +29 -0
- package/README.md +116 -37
- package/index.js +10 -0
- package/lib/middleware/authenticationmw.js +13 -2
- package/lib/middleware/enforceratelimitmw.js +135 -0
- package/lib/middleware/validatepermissionsmw.js +1 -1
- package/lib/middleware/webhookhandlermw.js +370 -14
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@rodit/rodit-auth-be` are documented here.
|
|
4
4
|
|
|
5
|
+
## [9.15.0] — 2026-08-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Outbound webhook SSRF controls** in `send_webhook` (CC-0001 residual):
|
|
10
|
+
before `fetch`, reject userinfo and private / loopback / link-local / ULA /
|
|
11
|
+
CGNAT / cloud-metadata hostnames and resolved A/AAAA addresses (same policy
|
|
12
|
+
as SLC `url-join-identity.js`); enforce JWT/peer `rodit_webhookcidr` when
|
|
13
|
+
present; resolve-once and pin the IP for the request via undici `lookup`.
|
|
14
|
+
`SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY` is unchanged for **public**
|
|
15
|
+
self-signed destinations.
|
|
16
|
+
- **`req.authenticatedRoditId` + login JSON `roditid`** after successful
|
|
17
|
+
`login_client` / `login_client_withnep413` signature verification (server-
|
|
18
|
+
derived peer token id only).
|
|
19
|
+
- **`enforceRateLimitFromClaims()`** middleware (and
|
|
20
|
+
`RoditClient#getClaimRateLimitMiddleware`) that consumes `req.rateLimit`
|
|
21
|
+
set by `validatepermissions`.
|
|
22
|
+
|
|
23
|
+
### Security
|
|
24
|
+
|
|
25
|
+
- Compromised or DNS-rebound JWT `rodit_webhookurl` values can no longer reach
|
|
26
|
+
internal targets through the SDK outbound webhook path.
|
|
27
|
+
|
|
28
|
+
## [9.14.1] — 2026-08-07
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Package version bumped to **9.14.1** (local `package.json` version restored).
|
|
33
|
+
|
|
5
34
|
## [9.14.0] — 2026-07-16
|
|
6
35
|
|
|
7
36
|
### Removed
|
package/README.md
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
A comprehensive Node.js SDK for implementing RODiT-based mutual authentication, authorization, self-configuration, and session management in Express.js applications.
|
|
4
4
|
|
|
5
|
-
**Version:** 9.
|
|
5
|
+
**Version:** 9.15.0
|
|
6
6
|
**License:** Proprietary
|
|
7
7
|
**Author:** Discernible IO
|
|
8
8
|
|
|
9
9
|
**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
10
|
|
|
11
|
+
**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).
|
|
12
|
+
|
|
11
13
|
**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
14
|
|
|
13
15
|
## Table of Contents
|
|
@@ -23,6 +25,7 @@ A comprehensive Node.js SDK for implementing RODiT-based mutual authentication,
|
|
|
23
25
|
- [Session lifetime and TTL](#session-lifetime-and-ttl)
|
|
24
26
|
- [Configuration](#configuration)
|
|
25
27
|
- [Environment Variables](#environment-variables)
|
|
28
|
+
- [Dynamic Rate Limiting](#dynamic-rate-limiting)
|
|
26
29
|
- [Session Storage Configuration](#session-storage-configuration)
|
|
27
30
|
- [Configuration Priority](#configuration-priority)
|
|
28
31
|
- [Logging & Monitoring](#logging--monitoring)
|
|
@@ -605,26 +608,33 @@ Permissions are configured in your RODiT token metadata using the `permissioned_
|
|
|
605
608
|
```
|
|
606
609
|
|
|
607
610
|
**Permission Format:**
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
611
|
+
|
|
612
|
+
Values are strings: optional scope prefix (`+` / `-`) plus a numeric rate:
|
|
613
|
+
|
|
614
|
+
| Value | Meaning |
|
|
615
|
+
|-------|---------|
|
|
616
|
+
| `"+0"` | Allowed (entity + properties scope); **unlimited** rate |
|
|
617
|
+
| `"+60"` | Allowed; **60 requests** per window (default window 60s) |
|
|
618
|
+
| `"-0"` | Allowed (properties-only scope); unlimited |
|
|
619
|
+
| (absent / no match) | Denied (`403`) |
|
|
620
|
+
|
|
621
|
+
`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
622
|
|
|
613
623
|
### Permission Validation Middleware
|
|
614
624
|
|
|
615
625
|
The `authorize` middleware validates that the authenticated user has permission to access the requested route:
|
|
616
626
|
|
|
617
627
|
```javascript
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
// Apply both authentication and authorization
|
|
628
|
+
// After RoditClient.create('server') and app.locals.roditClient = roditClient
|
|
629
|
+
const authenticate = (req, res, next) => roditClient.authenticate(req, res, next)
|
|
630
|
+
const authorize = (req, res, next) => roditClient.authorize(req, res, next)
|
|
631
|
+
const enforceClaimLimits = roditClient.getClaimRateLimitMiddleware()
|
|
632
|
+
|
|
633
|
+
// Auth + permission allow/deny
|
|
625
634
|
app.use('/api/admin', authenticate, authorize, adminRoutes)
|
|
626
|
-
|
|
627
|
-
|
|
635
|
+
|
|
636
|
+
// Auth + permissions + per-route claim quotas from permissioned_routes
|
|
637
|
+
app.use('/api/cruda', authenticate, authorize, enforceClaimLimits(), crudaRoutes)
|
|
628
638
|
```
|
|
629
639
|
|
|
630
640
|
### Permission Enforcement
|
|
@@ -1186,16 +1196,33 @@ const dbPath = config.get('API_DEFAULT_OPTIONS.DB_PATH')
|
|
|
1186
1196
|
|
|
1187
1197
|
### Dynamic Rate Limiting
|
|
1188
1198
|
|
|
1199
|
+
Two complementary limiters:
|
|
1200
|
+
|
|
1201
|
+
1. **Global** (`getRateLimitMiddleware` / `ratelimitmw`) — from own RODiT `max_requests` / `maxrq_window` (or fixed numbers). Applies across routes.
|
|
1202
|
+
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.
|
|
1203
|
+
|
|
1189
1204
|
```javascript
|
|
1190
|
-
//
|
|
1205
|
+
// 1) Global limiter from own RODiT metadata
|
|
1191
1206
|
const configObject = await roditClient.getConfigOwnRodit()
|
|
1192
1207
|
const metadata = configObject.own_rodit.metadata
|
|
1193
1208
|
if (metadata.max_requests && metadata.maxrq_window) {
|
|
1194
|
-
const maxRequests = parseInt(metadata.max_requests)
|
|
1195
|
-
const windowSeconds = parseInt(metadata.maxrq_window)
|
|
1209
|
+
const maxRequests = parseInt(metadata.max_requests, 10)
|
|
1210
|
+
const windowSeconds = parseInt(metadata.maxrq_window, 10)
|
|
1196
1211
|
const rateLimiter = roditClient.getRateLimitMiddleware()
|
|
1197
|
-
|
|
1198
|
-
|
|
1212
|
+
// Note: factory args are (maxRequests, windowMinutes)
|
|
1213
|
+
app.use(rateLimiter(maxRequests, Math.ceil(windowSeconds / 60) || 1))
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// 2) Per-route claim enforcement (after authenticate + validatepermissions)
|
|
1217
|
+
const { authenticate_apicall, validatepermissions, enforceRateLimitFromClaims } =
|
|
1218
|
+
require('@rodit/rodit-auth-be')
|
|
1219
|
+
// Or: roditClient.getClaimRateLimitMiddleware()
|
|
1220
|
+
app.use(
|
|
1221
|
+
'/api',
|
|
1222
|
+
(req, res, next) => roditClient.authenticate(req, res, next),
|
|
1223
|
+
validatepermissions,
|
|
1224
|
+
enforceRateLimitFromClaims()
|
|
1225
|
+
)
|
|
1199
1226
|
```
|
|
1200
1227
|
|
|
1201
1228
|
### Environment Variables
|
|
@@ -1249,8 +1276,8 @@ export LOKI_TLS_SKIP_VERIFY=false # true to skip TLS verification
|
|
|
1249
1276
|
|
|
1250
1277
|
#### Security Options
|
|
1251
1278
|
```bash
|
|
1252
|
-
// Webhook
|
|
1253
|
-
export
|
|
1279
|
+
// Webhook TLS (public destinations only; private/metadata URLs are always blocked)
|
|
1280
|
+
export SECURITY_OPTIONS_WEBHOOK_TLS_SKIP_VERIFY=false # true for self-signed public webhook hosts
|
|
1254
1281
|
// Login mode control (see Login Mode section below)
|
|
1255
1282
|
export SECURITY_OPTIONS_LOGIN_MODE=partner # partner, promiscuous, or p2p
|
|
1256
1283
|
// Security thresholds
|
|
@@ -1487,26 +1514,40 @@ performanceService.recordMetric('user_action', 1, {
|
|
|
1487
1514
|
})
|
|
1488
1515
|
```
|
|
1489
1516
|
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1517
|
+
## Webhooks
|
|
1518
|
+
|
|
1519
|
+
### Overview
|
|
1493
1520
|
|
|
1494
|
-
The SDK supports sending webhooks to multiple endpoints for important events. Webhook URLs
|
|
1521
|
+
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
1522
|
|
|
1496
1523
|
**Key Features:**
|
|
1497
1524
|
- **Custom Endpoints** - Send webhooks to any endpoint path (e.g., `/hooks/wake`, `/hooks/agent`, `/webhook`)
|
|
1498
|
-
- **
|
|
1499
|
-
- **
|
|
1525
|
+
- **Ed25519 signed** - Payload + timestamp signed; receivers verify via `X-Signature` / signer identity headers
|
|
1526
|
+
- **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
|
|
1527
|
+
- **Self-signed TLS (public only)** - `SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY=true` still allowed for **public** destinations
|
|
1500
1528
|
|
|
1501
|
-
|
|
1529
|
+
Peer RODiT metadata:
|
|
1502
1530
|
|
|
1503
1531
|
```json
|
|
1504
1532
|
{
|
|
1505
|
-
"webhook_url": "
|
|
1506
|
-
"webhook_cidr": "0.0.0.0/0"
|
|
1533
|
+
"webhook_url": "hooks.example.com",
|
|
1534
|
+
"webhook_cidr": "0.0.0.0/0"
|
|
1507
1535
|
}
|
|
1508
1536
|
```
|
|
1509
1537
|
|
|
1538
|
+
- `webhook_url` — host (optional path); SDK POSTs `https://{webhook_url}{endpoint}`
|
|
1539
|
+
- `webhook_cidr` — allowlist for the **resolved** destination IP (`0.0.0.0/0` / empty = any public IP still subject to the SSRF blocklist)
|
|
1540
|
+
|
|
1541
|
+
Outbound rejection codes (returned as `{ isValid: false, error: { code, message, requestId } }`):
|
|
1542
|
+
|
|
1543
|
+
| Code | Meaning |
|
|
1544
|
+
|------|---------|
|
|
1545
|
+
| `WEBHOOK_URL_MISSING` | No `rodit_webhookurl` on `req.user` |
|
|
1546
|
+
| `WEBHOOK_URL_INVALID` | Bad URL / userinfo / scheme |
|
|
1547
|
+
| `WEBHOOK_URL_BLOCKED` | Private, loopback, or metadata target |
|
|
1548
|
+
| `WEBHOOK_URL_UNRESOLVABLE` | DNS lookup failed |
|
|
1549
|
+
| `WEBHOOK_CIDR_DENIED` | Resolved IP outside peer `webhook_cidr` |
|
|
1550
|
+
|
|
1510
1551
|
### Sending Webhooks to Default Endpoint
|
|
1511
1552
|
|
|
1512
1553
|
Send webhooks to the default `/webhook` endpoint:
|
|
@@ -2079,16 +2120,24 @@ Handle Express login requests from clients. Validates RODiT credentials and issu
|
|
|
2079
2120
|
app.post('/api/login', (req, res) => roditClient.login_client(req, res))
|
|
2080
2121
|
```
|
|
2081
2122
|
|
|
2082
|
-
**Request Body:** `
|
|
2123
|
+
**Request Body:** `accountid` or `roditid` (exactly one non-empty), `timestamp` (and/or `timestamp_iso`), and `base64url_signature` (or `roditid_base64url_signature`).
|
|
2124
|
+
|
|
2125
|
+
**On success:**
|
|
2126
|
+
- Sets `req.authenticatedRoditId` to the **verified** peer token id (`peer_rodit.token_id`) — do not use `req.body.roditid` for app hooks
|
|
2127
|
+
- JSON body includes the same value as `roditid` (in addition to `jwt_token` / `requestId`)
|
|
2128
|
+
- Creates a server session keyed by that peer id; issues JWT (`New-Token` header + body)
|
|
2083
2129
|
|
|
2084
2130
|
**Response:**
|
|
2085
2131
|
```javascript
|
|
2086
2132
|
{
|
|
2087
2133
|
jwt_token: '<jwt-token>',
|
|
2088
|
-
requestId: '01HQXYZ...'
|
|
2134
|
+
requestId: '01HQXYZ...',
|
|
2135
|
+
roditid: '<verified-peer-token-id>'
|
|
2089
2136
|
}
|
|
2090
2137
|
```
|
|
2091
2138
|
|
|
2139
|
+
`login_client_withnep413` returns the same success shape (`jwt_token`, `requestId`, `roditid`) and also sets `req.authenticatedRoditId`.
|
|
2140
|
+
|
|
2092
2141
|
##### logout_client(req, res)
|
|
2093
2142
|
|
|
2094
2143
|
Handle Express logout requests. Closes session and invalidates JWT token.
|
|
@@ -2275,6 +2324,23 @@ app.use(limiter)
|
|
|
2275
2324
|
|
|
2276
2325
|
**Returns:** Express middleware function
|
|
2277
2326
|
|
|
2327
|
+
##### getClaimRateLimitMiddleware()
|
|
2328
|
+
|
|
2329
|
+
Get the middleware factory that enforces per-route limits from
|
|
2330
|
+
`req.rateLimit` (populated by `validatepermissions` / `authorize`).
|
|
2331
|
+
|
|
2332
|
+
Place **after** authentication and permission validation. Skips when
|
|
2333
|
+
`req.rateLimit` is missing or `unlimited: true`. Default window is
|
|
2334
|
+
`req.rateLimit.timeWindow` seconds (60). Optional `{ store }` Map-like
|
|
2335
|
+
override for multi-instance deployments.
|
|
2336
|
+
|
|
2337
|
+
```javascript
|
|
2338
|
+
const enforceRateLimitFromClaims = roditClient.getClaimRateLimitMiddleware()
|
|
2339
|
+
app.use('/api', authenticate, authorize, enforceRateLimitFromClaims())
|
|
2340
|
+
```
|
|
2341
|
+
|
|
2342
|
+
**Returns:** Middleware factory `(options?) => Express middleware`
|
|
2343
|
+
|
|
2278
2344
|
##### getPerformanceService()
|
|
2279
2345
|
|
|
2280
2346
|
Get the performance tracking service.
|
|
@@ -2310,7 +2376,12 @@ const webhookHandler = roditClient.getWebhookHandler()
|
|
|
2310
2376
|
|
|
2311
2377
|
##### send_webhook(payload, req)
|
|
2312
2378
|
|
|
2313
|
-
Send a webhook notification.
|
|
2379
|
+
Send a webhook notification to the peer URL in `req.user.rodit_webhookurl`.
|
|
2380
|
+
|
|
2381
|
+
Outbound delivery rejects private / loopback / metadata destinations (and
|
|
2382
|
+
resolved addresses), enforces peer JWT `rodit_webhookcidr` when set, and pins
|
|
2383
|
+
DNS for the request. `SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY` still applies
|
|
2384
|
+
to **public** self-signed endpoints only.
|
|
2314
2385
|
|
|
2315
2386
|
```javascript
|
|
2316
2387
|
const result = await roditClient.send_webhook({
|
|
@@ -2318,16 +2389,23 @@ const result = await roditClient.send_webhook({
|
|
|
2318
2389
|
data: { userId: '123', action: 'login' },
|
|
2319
2390
|
isError: false
|
|
2320
2391
|
}, req)
|
|
2392
|
+
|
|
2393
|
+
if (!result.isValid) {
|
|
2394
|
+
// e.g. WEBHOOK_URL_BLOCKED, WEBHOOK_CIDR_DENIED, WEBHOOK_SEND_ERROR
|
|
2395
|
+
logger.warn('Webhook not delivered', result.error)
|
|
2396
|
+
}
|
|
2321
2397
|
```
|
|
2322
2398
|
|
|
2399
|
+
Aliases: `sendWebhook(payload, req)`, `sendWebhookToEndpoint(payload, endpoint, req)`.
|
|
2400
|
+
|
|
2323
2401
|
**Parameters:**
|
|
2324
2402
|
- `payload` (Object): Webhook payload
|
|
2325
2403
|
- `event` (string): Event name
|
|
2326
2404
|
- `data` (Object): Event data
|
|
2327
2405
|
- `isError` (boolean): Whether this is an error event
|
|
2328
|
-
- `req` (Object): Express request
|
|
2406
|
+
- `req` (Object): Express request with authenticated `req.user` (peer JWT claims)
|
|
2329
2407
|
|
|
2330
|
-
**Returns:** `Promise<Object>`
|
|
2408
|
+
**Returns:** `Promise<Object>` — success `{ isValid: true, message, requestId, duration }` or `{ isValid: false, error: { code, message, requestId } }`
|
|
2331
2409
|
|
|
2332
2410
|
### Exported Components
|
|
2333
2411
|
|
|
@@ -2363,6 +2441,7 @@ const {
|
|
|
2363
2441
|
versioningMiddleware, // API versioning
|
|
2364
2442
|
loggingmw, // Logging middleware
|
|
2365
2443
|
ratelimitmw, // Rate limiting middleware
|
|
2444
|
+
enforceRateLimitFromClaims, // Consumes req.rateLimit from validatepermissions
|
|
2366
2445
|
versionManager, // Version manager
|
|
2367
2446
|
VersionManager, // Version manager class
|
|
2368
2447
|
nearorg_rpc_timestamp // Blockchain RPC timestamp function
|
|
@@ -2393,8 +2472,8 @@ When you call `roditClient.getConfigOwnRodit()`, you get access to these metadat
|
|
|
2393
2472
|
| `serviceprovider_signature` | string | Cryptographic signature for verification |
|
|
2394
2473
|
| `subjectuniqueidentifier_url` | string | Primary API service endpoint |
|
|
2395
2474
|
| `userselected_dn` | string | User-selected display name |
|
|
2396
|
-
| `webhook_cidr` | string |
|
|
2397
|
-
| `webhook_url` | string |
|
|
2475
|
+
| `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) |
|
|
2476
|
+
| `webhook_url` | string | Peer webhook host used as `rodit_webhookurl` in issued JWTs |
|
|
2398
2477
|
|
|
2399
2478
|
## Best Practices
|
|
2400
2479
|
|
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
|
|
@@ -1950,6 +1959,7 @@ module.exports = {
|
|
|
1950
1959
|
versioningMiddleware,
|
|
1951
1960
|
loggingmw,
|
|
1952
1961
|
ratelimitmw,
|
|
1962
|
+
enforceRateLimitFromClaims,
|
|
1953
1963
|
versionManager,
|
|
1954
1964
|
VersionManager,
|
|
1955
1965
|
// Blockchain service functions
|
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "9.15.0",
|
|
4
4
|
"description": "RODiT-based authentication system for Express.js applications",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"repository": {
|
|
48
48
|
"type": "git",
|
|
49
|
-
"url": "
|
|
49
|
+
"url": "https://github.com/discernible-io/rodit-auth-be.git"
|
|
50
50
|
},
|
|
51
51
|
"publishConfig": {
|
|
52
52
|
"access": "public"
|