web-agent-bridge 3.2.0 → 3.4.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/LICENSE +84 -72
- package/README.ar.md +1304 -1152
- package/README.md +298 -1635
- package/bin/agent-runner.js +474 -474
- package/bin/cli.js +237 -138
- package/bin/wab-init.js +223 -0
- package/bin/wab.js +80 -80
- package/examples/azure-dns-wab.js +83 -0
- package/examples/bidi-agent.js +119 -119
- package/examples/cloudflare-wab-dns.js +121 -0
- package/examples/cpanel-wab-dns.js +114 -0
- package/examples/cross-site-agent.js +91 -91
- package/examples/dns-discovery-agent.js +166 -0
- package/examples/gcp-dns-wab.js +76 -0
- package/examples/governance-agent.js +169 -0
- package/examples/mcp-agent.js +94 -94
- package/examples/next-app-router/README.md +44 -44
- package/examples/plesk-wab-dns.js +103 -0
- package/examples/puppeteer-agent.js +108 -108
- package/examples/route53-wab-dns.js +144 -0
- package/examples/saas-dashboard/README.md +55 -55
- package/examples/safe-mode-agent.js +96 -0
- package/examples/shopify-hydrogen/README.md +74 -74
- package/examples/vision-agent.js +171 -171
- package/examples/wab-sign.js +74 -0
- package/examples/wab-verify.js +60 -0
- package/examples/wordpress-elementor/README.md +77 -77
- package/package.json +19 -6
- package/public/.well-known/agent-tools.json +180 -180
- package/public/.well-known/ai-assets.json +59 -59
- package/public/.well-known/security.txt +8 -0
- package/public/.well-known/wab.json +28 -0
- package/public/activate.html +368 -0
- package/public/adoption-metrics.html +188 -0
- package/public/agent-workspace.html +349 -349
- package/public/ai.html +198 -198
- package/public/api.html +413 -412
- package/public/azure-dns-integration.html +289 -0
- package/public/browser.html +486 -486
- package/public/cloudflare-integration.html +380 -0
- package/public/commander-dashboard.html +243 -243
- package/public/cookies.html +210 -210
- package/public/cpanel-integration.html +398 -0
- package/public/css/agent-workspace.css +1713 -1713
- package/public/css/premium.css +317 -317
- package/public/css/styles.css +1263 -1235
- package/public/dashboard.html +707 -706
- package/public/dns.html +436 -0
- package/public/docs.html +588 -587
- package/public/feed.xml +89 -89
- package/public/gcp-dns-integration.html +318 -0
- package/public/growth.html +465 -463
- package/public/index.html +1266 -982
- package/public/integrations.html +556 -0
- package/public/js/activate.js +145 -0
- package/public/js/agent-workspace.js +1740 -1740
- package/public/js/auth-nav.js +65 -31
- package/public/js/auth-redirect.js +12 -12
- package/public/js/cookie-consent.js +56 -56
- package/public/js/dns.js +438 -0
- package/public/js/wab-demo-page.js +721 -721
- package/public/js/ws-client.js +74 -74
- package/public/llms-full.txt +360 -360
- package/public/llms.txt +125 -125
- package/public/login.html +85 -85
- package/public/mesh-dashboard.html +328 -328
- package/public/openapi.json +669 -580
- package/public/phone-shield.html +281 -0
- package/public/plesk-integration.html +375 -0
- package/public/premium-dashboard.html +2489 -2489
- package/public/premium.html +793 -793
- package/public/privacy.html +297 -297
- package/public/provider-onboarding.html +172 -0
- package/public/provider-sandbox.html +134 -0
- package/public/providers.html +359 -0
- package/public/register.html +105 -105
- package/public/registrar-integrations.html +141 -0
- package/public/robots.txt +99 -87
- package/public/route53-integration.html +531 -0
- package/public/script/wab-consent.d.ts +36 -36
- package/public/script/wab-consent.js +104 -104
- package/public/script/wab-schema.js +131 -131
- package/public/script/wab.d.ts +108 -108
- package/public/script/wab.min.js +580 -580
- package/public/security.txt +8 -0
- package/public/shieldqr.html +231 -0
- package/public/sitemap.xml +6 -0
- package/public/terms.html +256 -256
- package/public/wab-trust.html +200 -0
- package/public/wab-vs-protocols.html +210 -0
- package/public/whitepaper.html +449 -0
- package/script/ai-agent-bridge.js +1754 -1754
- package/sdk/README.md +99 -99
- package/sdk/agent-mesh.js +449 -449
- package/sdk/auto-discovery.js +288 -0
- package/sdk/commander.js +262 -262
- package/sdk/governance.js +262 -0
- package/sdk/index.d.ts +464 -464
- package/sdk/index.js +25 -1
- package/sdk/multi-agent.js +318 -318
- package/sdk/package.json +2 -2
- package/sdk/safe-mode.js +221 -0
- package/sdk/safety-shield.js +219 -0
- package/sdk/schema-discovery.js +83 -83
- package/server/adapters/index.js +520 -520
- package/server/config/plans.js +367 -367
- package/server/config/secrets.js +102 -102
- package/server/control-plane/index.js +301 -301
- package/server/data-plane/index.js +354 -354
- package/server/index.js +670 -427
- package/server/llm/index.js +404 -404
- package/server/middleware/adminAuth.js +35 -35
- package/server/middleware/auth.js +50 -50
- package/server/middleware/featureGate.js +88 -88
- package/server/middleware/rateLimits.js +100 -100
- package/server/middleware/sensitiveAction.js +157 -0
- package/server/migrations/001_add_analytics_indexes.sql +7 -7
- package/server/migrations/002_premium_features.sql +418 -418
- package/server/migrations/003_ads_integer_cents.sql +33 -33
- package/server/migrations/004_agent_os.sql +158 -158
- package/server/migrations/005_marketplace_metering.sql +126 -126
- package/server/migrations/007_governance.sql +106 -0
- package/server/migrations/008_plans.sql +144 -0
- package/server/migrations/009_shieldqr.sql +30 -0
- package/server/migrations/010_extended_trust.sql +33 -0
- package/server/models/adapters/index.js +33 -33
- package/server/models/adapters/mysql.js +183 -183
- package/server/models/adapters/postgresql.js +172 -172
- package/server/models/adapters/sqlite.js +7 -7
- package/server/models/db.js +740 -681
- package/server/observability/failure-analysis.js +337 -337
- package/server/observability/index.js +394 -394
- package/server/protocol/capabilities.js +223 -223
- package/server/protocol/index.js +243 -243
- package/server/protocol/schema.js +584 -584
- package/server/registry/certification.js +271 -271
- package/server/registry/index.js +326 -326
- package/server/routes/admin-plans.js +76 -0
- package/server/routes/admin-premium.js +673 -671
- package/server/routes/admin-shieldqr.js +90 -0
- package/server/routes/admin-trust-monitor.js +83 -0
- package/server/routes/admin.js +549 -261
- package/server/routes/ads.js +130 -130
- package/server/routes/agent-workspace.js +540 -540
- package/server/routes/api.js +150 -150
- package/server/routes/auth.js +71 -71
- package/server/routes/billing.js +57 -45
- package/server/routes/commander.js +316 -316
- package/server/routes/demo-showcase.js +332 -332
- package/server/routes/demo-store.js +154 -0
- package/server/routes/discovery.js +2348 -417
- package/server/routes/gateway.js +173 -157
- package/server/routes/governance.js +208 -0
- package/server/routes/license.js +251 -240
- package/server/routes/mesh.js +469 -469
- package/server/routes/noscript.js +543 -543
- package/server/routes/plans.js +33 -0
- package/server/routes/premium-v2.js +686 -686
- package/server/routes/premium.js +724 -724
- package/server/routes/providers.js +650 -0
- package/server/routes/runtime.js +2148 -2147
- package/server/routes/shieldqr.js +88 -0
- package/server/routes/sovereign.js +465 -385
- package/server/routes/universal.js +200 -185
- package/server/routes/wab-api.js +850 -501
- package/server/runtime/container-worker.js +111 -111
- package/server/runtime/container.js +448 -448
- package/server/runtime/distributed-worker.js +362 -362
- package/server/runtime/event-bus.js +210 -210
- package/server/runtime/index.js +253 -253
- package/server/runtime/queue.js +599 -599
- package/server/runtime/replay.js +666 -666
- package/server/runtime/sandbox.js +266 -266
- package/server/runtime/scheduler.js +534 -534
- package/server/runtime/session-engine.js +293 -293
- package/server/runtime/state-manager.js +188 -188
- package/server/security/cross-site-redactor.js +196 -0
- package/server/security/dry-run.js +180 -0
- package/server/security/human-gate-rate-limit.js +147 -0
- package/server/security/human-gate-transports.js +178 -0
- package/server/security/human-gate.js +281 -0
- package/server/security/index.js +368 -368
- package/server/security/intent-engine.js +245 -0
- package/server/security/reward-guard.js +171 -0
- package/server/security/rollback-store.js +239 -0
- package/server/security/token-scope.js +404 -0
- package/server/security/url-policy.js +139 -0
- package/server/services/agent-chat.js +506 -506
- package/server/services/agent-learning.js +601 -575
- package/server/services/agent-memory.js +625 -625
- package/server/services/agent-mesh.js +555 -539
- package/server/services/agent-symphony.js +717 -717
- package/server/services/agent-tasks.js +1807 -1807
- package/server/services/api-key-engine.js +292 -261
- package/server/services/cluster.js +894 -894
- package/server/services/commander.js +738 -738
- package/server/services/edge-compute.js +440 -440
- package/server/services/email.js +233 -204
- package/server/services/governance.js +466 -0
- package/server/services/hosted-runtime.js +205 -205
- package/server/services/lfd.js +635 -635
- package/server/services/local-ai.js +389 -389
- package/server/services/marketplace.js +270 -270
- package/server/services/metering.js +182 -182
- package/server/services/modules/affiliate-intelligence.js +93 -93
- package/server/services/modules/agent-firewall.js +90 -90
- package/server/services/modules/bounty.js +89 -89
- package/server/services/modules/collective-bargaining.js +92 -92
- package/server/services/modules/dark-pattern.js +66 -66
- package/server/services/modules/gov-intelligence.js +45 -45
- package/server/services/modules/neural.js +55 -55
- package/server/services/modules/notary.js +49 -49
- package/server/services/modules/price-time-machine.js +86 -86
- package/server/services/modules/protocol.js +104 -104
- package/server/services/negotiation.js +439 -439
- package/server/services/plans.js +214 -0
- package/server/services/plugins.js +771 -771
- package/server/services/premium.js +1 -1
- package/server/services/price-intelligence.js +566 -566
- package/server/services/price-shield.js +1137 -1137
- package/server/services/provider-clients.js +740 -0
- package/server/services/reputation.js +465 -465
- package/server/services/search-engine.js +357 -357
- package/server/services/security.js +513 -513
- package/server/services/self-healing.js +843 -843
- package/server/services/shieldqr.js +322 -0
- package/server/services/sovereign-shield.js +542 -0
- package/server/services/ssl-inspector.js +42 -0
- package/server/services/ssl-monitor.js +167 -0
- package/server/services/stripe.js +205 -192
- package/server/services/swarm.js +788 -788
- package/server/services/universal-scraper.js +662 -661
- package/server/services/verification.js +481 -481
- package/server/services/vision.js +1163 -1163
- package/server/services/wab-crypto.js +178 -0
- package/server/utils/cache.js +125 -125
- package/server/utils/migrate.js +81 -81
- package/server/utils/safe-fetch.js +228 -0
- package/server/utils/secureFields.js +50 -50
- package/server/ws.js +161 -161
- package/templates/artisan-marketplace.yaml +104 -104
- package/templates/book-price-scout.yaml +98 -98
- package/templates/electronics-price-tracker.yaml +108 -108
- package/templates/flight-deal-hunter.yaml +113 -113
- package/templates/freelancer-direct.yaml +116 -116
- package/templates/grocery-price-compare.yaml +93 -93
- package/templates/hotel-direct-booking.yaml +113 -113
- package/templates/local-services.yaml +98 -98
- package/templates/olive-oil-tunisia.yaml +88 -88
- package/templates/organic-farm-fresh.yaml +101 -101
- package/templates/restaurant-direct.yaml +97 -97
- package/public/score.html +0 -263
- package/server/migrations/006_growth_suite.sql +0 -138
- package/server/routes/growth.js +0 -962
- package/server/services/fairness-engine.js +0 -409
- package/server/services/fairness.js +0 -420
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WAB Safety Shield — Scoped Session Tokens
|
|
5
|
+
*
|
|
6
|
+
* Implements the runtime side of WAB SPEC §8.7 (Scoped Session Tokens).
|
|
7
|
+
*
|
|
8
|
+
* Threat model: a leaked or compromised session token issued for one
|
|
9
|
+
* environment / one access level (e.g. `read` in `staging`) MUST NOT be
|
|
10
|
+
* usable to perform a destructive operation in production. This is the
|
|
11
|
+
* safety primitive that prevents PocketOS-class incidents where a single
|
|
12
|
+
* unscoped token straddles staging and production with full write access.
|
|
13
|
+
*
|
|
14
|
+
* Design:
|
|
15
|
+
* - Scope is a triplet (access, env, resources)
|
|
16
|
+
* access : 'read' | 'write' | 'admin' (hierarchy: admin > write > read)
|
|
17
|
+
* env : 'sandbox' | 'staging' | 'production' | '*' ('*' = any)
|
|
18
|
+
* resources : array of glob patterns OR ['*'] (default ['*'])
|
|
19
|
+
* - A separate boolean axis: destructive=true is required for any command
|
|
20
|
+
* that matches the SPEC default destructive verb list OR the site's
|
|
21
|
+
* wab.json `destructiveActions` array. `read` scope NEVER allows
|
|
22
|
+
* destructive, regardless of environment. `admin` always does.
|
|
23
|
+
* - Scopes can only be NARROWED, never widened. When a token issuer is
|
|
24
|
+
* itself scoped (delegation), the issued scope is the intersection of
|
|
25
|
+
* parent and requested.
|
|
26
|
+
*
|
|
27
|
+
* Error codes (returned to clients):
|
|
28
|
+
* INVALID_SCOPE — scope string/object did not parse
|
|
29
|
+
* INSUFFICIENT_SCOPE — token doesn't satisfy required access level
|
|
30
|
+
* ENV_MISMATCH — token env doesn't include requested env
|
|
31
|
+
* READONLY_VIOLATION — read-scope token tried to perform a write
|
|
32
|
+
* DESTRUCTIVE_REQUIRES_WRITE — token cannot perform destructive ops
|
|
33
|
+
* RESOURCE_OUT_OF_SCOPE — resource glob doesn't include the target
|
|
34
|
+
*
|
|
35
|
+
* This module is intentionally pure (no DB, no Express deps) so it can be
|
|
36
|
+
* unit-tested in isolation.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// ─── Constants ───────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const ACCESS_LEVELS = ['read', 'write', 'admin'];
|
|
42
|
+
const ENVIRONMENTS = ['sandbox', 'staging', 'production'];
|
|
43
|
+
const ANY_ENV = '*';
|
|
44
|
+
const ANY_RESOURCE = '*';
|
|
45
|
+
|
|
46
|
+
// SPEC §8.7.3 — default destructive verb list (lower-case). Sites may
|
|
47
|
+
// extend this via wab.json `destructiveActions: [...]` and may suppress
|
|
48
|
+
// individual verbs via `nonDestructiveActions: [...]`.
|
|
49
|
+
const DEFAULT_DESTRUCTIVE_VERBS = Object.freeze([
|
|
50
|
+
'delete', 'destroy', 'drop', 'truncate', 'purge', 'wipe', 'erase',
|
|
51
|
+
'remove', 'unlink', 'rm', 'rmdir',
|
|
52
|
+
'reset', 'reinit', 'reformat', 'format',
|
|
53
|
+
'shutdown', 'terminate', 'kill',
|
|
54
|
+
'revoke', 'disable', 'deactivate',
|
|
55
|
+
'volume-delete', 'volumedelete', 'db-drop', 'database-drop',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const DESTRUCTIVE_VERBS_SET = new Set(DEFAULT_DESTRUCTIVE_VERBS);
|
|
59
|
+
|
|
60
|
+
// ─── Aliases (legacy / human-friendly inputs) ────────────────────────
|
|
61
|
+
|
|
62
|
+
const ACCESS_ALIASES = {
|
|
63
|
+
readonly: 'read',
|
|
64
|
+
ro: 'read',
|
|
65
|
+
read: 'read',
|
|
66
|
+
rw: 'write',
|
|
67
|
+
write: 'write',
|
|
68
|
+
full: 'admin',
|
|
69
|
+
admin: 'admin',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const ENV_ALIASES = {
|
|
73
|
+
prod: 'production',
|
|
74
|
+
production: 'production',
|
|
75
|
+
live: 'production',
|
|
76
|
+
staging: 'staging',
|
|
77
|
+
stage: 'staging',
|
|
78
|
+
test: 'sandbox',
|
|
79
|
+
sandbox: 'sandbox',
|
|
80
|
+
dev: 'sandbox',
|
|
81
|
+
development: 'sandbox',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ─── Errors ──────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
class ScopeError extends Error {
|
|
87
|
+
constructor(code, message) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = 'ScopeError';
|
|
90
|
+
this.code = code;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Parsing & canonicalisation ──────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse arbitrary scope input into a canonical object.
|
|
98
|
+
*
|
|
99
|
+
* Accepts:
|
|
100
|
+
* - undefined / null → admin/* (legacy unscoped tokens)
|
|
101
|
+
* - string "readonly" → { access:'read', env:['*'], resources:['*'] }
|
|
102
|
+
* - string "read:staging" → { access:'read', env:['staging'], resources:['*'] }
|
|
103
|
+
* - string "write:staging,prod"→ { access:'write', env:['staging','production'], resources:['*'] }
|
|
104
|
+
* - string "read:*:cart.*" → { access:'read', env:['*'], resources:['cart.*'] }
|
|
105
|
+
* - object { access, env, resources }
|
|
106
|
+
*
|
|
107
|
+
* Returns canonical: { access, envs:Set<string>|null, resources:string[],
|
|
108
|
+
* legacyUnscoped:bool }
|
|
109
|
+
*
|
|
110
|
+
* envs === null ⇨ any environment ('*')
|
|
111
|
+
*
|
|
112
|
+
* Throws ScopeError('INVALID_SCOPE') on malformed input.
|
|
113
|
+
*/
|
|
114
|
+
function parseScope(input) {
|
|
115
|
+
// Legacy: no scope provided. Pre-§8.7 behaviour preserved for backward compat.
|
|
116
|
+
if (input == null || input === '' || input === '*') {
|
|
117
|
+
return {
|
|
118
|
+
access: 'admin',
|
|
119
|
+
envs: null,
|
|
120
|
+
resources: ['*'],
|
|
121
|
+
legacyUnscoped: true,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let access;
|
|
126
|
+
let envParts;
|
|
127
|
+
let resources;
|
|
128
|
+
|
|
129
|
+
if (typeof input === 'string') {
|
|
130
|
+
const segments = input.split(':').map((s) => s.trim()).filter(Boolean);
|
|
131
|
+
if (segments.length === 0) throw new ScopeError('INVALID_SCOPE', 'empty scope string');
|
|
132
|
+
access = _normalizeAccess(segments[0]);
|
|
133
|
+
envParts = segments[1] ? segments[1].split(',').map((s) => s.trim()) : ['*'];
|
|
134
|
+
resources = segments[2] ? segments[2].split(',').map((s) => s.trim()) : ['*'];
|
|
135
|
+
} else if (typeof input === 'object') {
|
|
136
|
+
access = _normalizeAccess(input.access || input.level || 'read');
|
|
137
|
+
const rawEnv = input.env != null ? input.env : (input.environment != null ? input.environment : '*');
|
|
138
|
+
envParts = Array.isArray(rawEnv) ? rawEnv : String(rawEnv).split(',');
|
|
139
|
+
envParts = envParts.map((s) => String(s).trim()).filter(Boolean);
|
|
140
|
+
if (envParts.length === 0) envParts = ['*'];
|
|
141
|
+
const rawRes = input.resources != null ? input.resources : '*';
|
|
142
|
+
resources = Array.isArray(rawRes) ? rawRes : [String(rawRes)];
|
|
143
|
+
resources = resources.map((s) => String(s).trim()).filter(Boolean);
|
|
144
|
+
if (resources.length === 0) resources = ['*'];
|
|
145
|
+
} else {
|
|
146
|
+
throw new ScopeError('INVALID_SCOPE', `unsupported scope input type: ${typeof input}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Resolve envs.
|
|
150
|
+
let envs;
|
|
151
|
+
if (envParts.includes('*') || envParts.includes(ANY_ENV)) {
|
|
152
|
+
envs = null;
|
|
153
|
+
} else {
|
|
154
|
+
envs = new Set();
|
|
155
|
+
for (const e of envParts) {
|
|
156
|
+
const norm = ENV_ALIASES[e.toLowerCase()];
|
|
157
|
+
if (!norm) throw new ScopeError('INVALID_SCOPE', `unknown environment "${e}"`);
|
|
158
|
+
envs.add(norm);
|
|
159
|
+
}
|
|
160
|
+
if (envs.size === 0) envs = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Validate resource patterns: limit to ASCII-safe glob, no spaces, ≤256 chars.
|
|
164
|
+
for (const r of resources) {
|
|
165
|
+
if (r.length > 256 || /[\s\x00-\x1f]/.test(r)) {
|
|
166
|
+
throw new ScopeError('INVALID_SCOPE', `invalid resource pattern "${r}"`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { access, envs, resources, legacyUnscoped: false };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function _normalizeAccess(raw) {
|
|
174
|
+
const a = ACCESS_ALIASES[String(raw).toLowerCase()];
|
|
175
|
+
if (!a) throw new ScopeError('INVALID_SCOPE', `unknown access level "${raw}"`);
|
|
176
|
+
return a;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Stable string form for logging / token serialisation. */
|
|
180
|
+
function formatScope(scope) {
|
|
181
|
+
if (!scope) return '*';
|
|
182
|
+
if (scope.legacyUnscoped) return '*';
|
|
183
|
+
const env = scope.envs == null ? '*' : Array.from(scope.envs).sort().join(',');
|
|
184
|
+
const res = scope.resources.join(',');
|
|
185
|
+
return `${scope.access}:${env}:${res}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Hierarchy & intersection ────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
function _accessRank(a) {
|
|
191
|
+
return ACCESS_LEVELS.indexOf(a);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Return the most restrictive scope that satisfies BOTH parent and child.
|
|
196
|
+
* Used when an issuer (already scoped) delegates a narrower scope to a
|
|
197
|
+
* sub-token — the result must never exceed the parent's authority.
|
|
198
|
+
*
|
|
199
|
+
* Throws ScopeError('INSUFFICIENT_SCOPE') if child requests more than parent.
|
|
200
|
+
*/
|
|
201
|
+
function intersectScopes(parent, child) {
|
|
202
|
+
const p = _ensureScope(parent);
|
|
203
|
+
const c = _ensureScope(child);
|
|
204
|
+
|
|
205
|
+
// Access: must be ≤ parent.
|
|
206
|
+
if (_accessRank(c.access) > _accessRank(p.access)) {
|
|
207
|
+
throw new ScopeError('INSUFFICIENT_SCOPE',
|
|
208
|
+
`requested access "${c.access}" exceeds parent "${p.access}"`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Envs.
|
|
212
|
+
let envs;
|
|
213
|
+
if (p.envs == null) {
|
|
214
|
+
envs = c.envs == null ? null : new Set(c.envs);
|
|
215
|
+
} else if (c.envs == null) {
|
|
216
|
+
envs = new Set(p.envs);
|
|
217
|
+
} else {
|
|
218
|
+
envs = new Set([...c.envs].filter((e) => p.envs.has(e)));
|
|
219
|
+
if (envs.size === 0) {
|
|
220
|
+
throw new ScopeError('ENV_MISMATCH', 'requested environments not allowed by parent');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Resources: child must be a subset (or '*' which inherits parent).
|
|
225
|
+
let resources;
|
|
226
|
+
if (c.resources.length === 1 && c.resources[0] === '*') {
|
|
227
|
+
resources = [...p.resources];
|
|
228
|
+
} else if (p.resources.length === 1 && p.resources[0] === '*') {
|
|
229
|
+
resources = [...c.resources];
|
|
230
|
+
} else {
|
|
231
|
+
// Each child pattern must be covered by at least one parent pattern.
|
|
232
|
+
for (const cp of c.resources) {
|
|
233
|
+
const ok = p.resources.some((pp) => _resourceCovers(pp, cp));
|
|
234
|
+
if (!ok) {
|
|
235
|
+
throw new ScopeError('INSUFFICIENT_SCOPE',
|
|
236
|
+
`requested resource "${cp}" exceeds parent`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
resources = [...c.resources];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { access: c.access, envs, resources, legacyUnscoped: false };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function _resourceCovers(parentPattern, childPattern) {
|
|
246
|
+
// Conservative: only consider trailing-* globs and exact equality.
|
|
247
|
+
if (parentPattern === '*' || parentPattern === childPattern) return true;
|
|
248
|
+
if (parentPattern.endsWith('.*') || parentPattern.endsWith('/*')) {
|
|
249
|
+
const prefix = parentPattern.slice(0, -1);
|
|
250
|
+
return childPattern === prefix.slice(0, -1) || childPattern.startsWith(prefix);
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function _ensureScope(s) {
|
|
256
|
+
return s && typeof s === 'object' && 'access' in s ? s : parseScope(s);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Destructive verb classification ─────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Decide whether `actionName` (within `siteConfig`) is destructive.
|
|
263
|
+
*
|
|
264
|
+
* Order:
|
|
265
|
+
* 1. siteConfig.nonDestructiveActions[] → forces non-destructive (override)
|
|
266
|
+
* 2. siteConfig.destructiveActions[] → forces destructive
|
|
267
|
+
* 3. SPEC default destructive verb list → fallback
|
|
268
|
+
*/
|
|
269
|
+
function isDestructiveAction(actionName, siteConfig = {}) {
|
|
270
|
+
if (!actionName) return false;
|
|
271
|
+
const raw = String(actionName);
|
|
272
|
+
// Insert a separator at camelCase boundaries BEFORE lowercasing so we can
|
|
273
|
+
// catch forms like "deleteVolume", "dropTable", "purgeBackups".
|
|
274
|
+
const camelExpanded = raw.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
275
|
+
const lower = camelExpanded;
|
|
276
|
+
|
|
277
|
+
const nonD = Array.isArray(siteConfig.nonDestructiveActions)
|
|
278
|
+
? siteConfig.nonDestructiveActions.map((s) => String(s).toLowerCase())
|
|
279
|
+
: [];
|
|
280
|
+
if (nonD.includes(raw.toLowerCase()) || nonD.includes(lower)) return false;
|
|
281
|
+
|
|
282
|
+
const extra = Array.isArray(siteConfig.destructiveActions)
|
|
283
|
+
? siteConfig.destructiveActions.map((s) => String(s).toLowerCase())
|
|
284
|
+
: [];
|
|
285
|
+
if (extra.includes(raw.toLowerCase()) || extra.includes(lower)) return true;
|
|
286
|
+
|
|
287
|
+
// Token-split match against the camelCase-expanded form.
|
|
288
|
+
const tokens = lower.split(/[\s.\-_/:]+/).filter(Boolean);
|
|
289
|
+
for (const t of tokens) {
|
|
290
|
+
if (DESTRUCTIVE_VERBS_SET.has(t)) return true;
|
|
291
|
+
if (extra.includes(t)) return true;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─── Authorisation decision ──────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The single authoritative authorisation check.
|
|
300
|
+
*
|
|
301
|
+
* @param {object} scope Canonical scope (from parseScope).
|
|
302
|
+
* @param {object} command { name, env, resource, action_kind?, destructive? }
|
|
303
|
+
* - name : action identifier (e.g. 'delete')
|
|
304
|
+
* - env : 'production' | 'staging' | 'sandbox'
|
|
305
|
+
* - resource : optional resource id (e.g. 'orders.cart')
|
|
306
|
+
* - action_kind : 'read' | 'write' | 'admin'
|
|
307
|
+
* (defaults: 'admin' if destructive,
|
|
308
|
+
* 'write' if name not GET-like, else 'read')
|
|
309
|
+
* - destructive : boolean override (otherwise inferred
|
|
310
|
+
* via isDestructiveAction + siteConfig)
|
|
311
|
+
* @param {object} siteConfig parsed wab.json
|
|
312
|
+
*
|
|
313
|
+
* @returns {{allowed: true} | {allowed: false, code: string, reason: string}}
|
|
314
|
+
*/
|
|
315
|
+
function authorize(scope, command, siteConfig = {}) {
|
|
316
|
+
const sc = _ensureScope(scope);
|
|
317
|
+
const cmd = command || {};
|
|
318
|
+
const env = cmd.env ? ENV_ALIASES[String(cmd.env).toLowerCase()] || cmd.env : null;
|
|
319
|
+
|
|
320
|
+
// 1. Environment match.
|
|
321
|
+
if (sc.envs != null && env && !sc.envs.has(env)) {
|
|
322
|
+
return {
|
|
323
|
+
allowed: false,
|
|
324
|
+
code: 'ENV_MISMATCH',
|
|
325
|
+
reason: `token does not include environment "${env}" (allowed: ${[...sc.envs].join(',')})`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 2. Destructive flag.
|
|
330
|
+
const destructive = cmd.destructive === true ||
|
|
331
|
+
isDestructiveAction(cmd.name, siteConfig);
|
|
332
|
+
|
|
333
|
+
if (destructive) {
|
|
334
|
+
if (sc.access === 'read') {
|
|
335
|
+
return {
|
|
336
|
+
allowed: false,
|
|
337
|
+
code: 'DESTRUCTIVE_REQUIRES_WRITE',
|
|
338
|
+
reason: `destructive action "${cmd.name}" cannot be performed by a read-scope token`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
// write and admin both pass the destructive gate; site policy may still
|
|
342
|
+
// require admin via an explicit action_kind hint below.
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 3. Access level.
|
|
346
|
+
const requiredAccess = _requiredAccessForCommand(cmd, destructive);
|
|
347
|
+
if (_accessRank(sc.access) < _accessRank(requiredAccess)) {
|
|
348
|
+
const code = (sc.access === 'read' && requiredAccess === 'write')
|
|
349
|
+
? 'READONLY_VIOLATION'
|
|
350
|
+
: 'INSUFFICIENT_SCOPE';
|
|
351
|
+
return {
|
|
352
|
+
allowed: false,
|
|
353
|
+
code,
|
|
354
|
+
reason: `command requires "${requiredAccess}" but token has "${sc.access}"`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 4. Resource glob.
|
|
359
|
+
if (cmd.resource && !_resourceMatchesAny(cmd.resource, sc.resources)) {
|
|
360
|
+
return {
|
|
361
|
+
allowed: false,
|
|
362
|
+
code: 'RESOURCE_OUT_OF_SCOPE',
|
|
363
|
+
reason: `resource "${cmd.resource}" not in token scope`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return { allowed: true };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function _requiredAccessForCommand(cmd, destructive) {
|
|
371
|
+
if (cmd.action_kind && ACCESS_LEVELS.includes(cmd.action_kind)) return cmd.action_kind;
|
|
372
|
+
if (destructive) return 'write';
|
|
373
|
+
const READ_ONLY_PATTERNS = /^(read|get|list|search|find|view|page-info|ping|discover|actions)/i;
|
|
374
|
+
if (cmd.name && READ_ONLY_PATTERNS.test(String(cmd.name))) return 'read';
|
|
375
|
+
return 'write';
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function _resourceMatchesAny(target, patterns) {
|
|
379
|
+
for (const p of patterns) {
|
|
380
|
+
if (p === '*' || p === target) return true;
|
|
381
|
+
if (p.endsWith('.*') || p.endsWith('/*')) {
|
|
382
|
+
const prefix = p.slice(0, -1);
|
|
383
|
+
if (target === prefix.slice(0, -1) || target.startsWith(prefix)) return true;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── Public API ──────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
module.exports = {
|
|
392
|
+
// parsing
|
|
393
|
+
parseScope,
|
|
394
|
+
formatScope,
|
|
395
|
+
intersectScopes,
|
|
396
|
+
// policy
|
|
397
|
+
authorize,
|
|
398
|
+
isDestructiveAction,
|
|
399
|
+
// diagnostics / introspection
|
|
400
|
+
ScopeError,
|
|
401
|
+
ACCESS_LEVELS,
|
|
402
|
+
ENVIRONMENTS,
|
|
403
|
+
DEFAULT_DESTRUCTIVE_VERBS,
|
|
404
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* URL Policy — guards public endpoints (e.g. /api/universal/extract) that
|
|
5
|
+
* accept arbitrary user URLs. Layered on top of the SSRF guard in
|
|
6
|
+
* server/utils/safe-fetch.js, this module enforces:
|
|
7
|
+
*
|
|
8
|
+
* 1. Scheme allow-list (https only by default).
|
|
9
|
+
* 2. TLD/host denylist (configurable via WAB_URL_DENY_HOSTS / DEFAULT_DENY).
|
|
10
|
+
* 3. Path denylist for obvious admin/credential/wp-login style targets that
|
|
11
|
+
* would suggest abuse.
|
|
12
|
+
* 4. Per-actor (IP / API-key / siteId) rate-limit independent of express
|
|
13
|
+
* router-level rate limiting.
|
|
14
|
+
*
|
|
15
|
+
* Decisions are recorded in `url_policy_audit` for review.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
const { db } = require('../models/db');
|
|
20
|
+
|
|
21
|
+
const DEFAULT_DENY_HOSTS = [
|
|
22
|
+
// Local/private/metadata is already blocked by safe-fetch; these are
|
|
23
|
+
// additional public hosts that have no legitimate scraping use case.
|
|
24
|
+
'login.microsoftonline.com',
|
|
25
|
+
'accounts.google.com',
|
|
26
|
+
'appleid.apple.com',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const DEFAULT_DENY_PATH_RE = /\/(?:wp-(?:login|admin)|administrator|phpmyadmin|\.git|\.env)(?:\/|\.|$|\?)/i;
|
|
30
|
+
|
|
31
|
+
const RATE_WINDOW_MS = 60_000;
|
|
32
|
+
const RATE_MAX = parseInt(process.env.WAB_URL_POLICY_RATE_MAX || '30', 10);
|
|
33
|
+
|
|
34
|
+
function _envHosts() {
|
|
35
|
+
return String(process.env.WAB_URL_DENY_HOSTS || '')
|
|
36
|
+
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
db.exec(`
|
|
40
|
+
CREATE TABLE IF NOT EXISTS url_policy_audit (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
actor TEXT,
|
|
43
|
+
url TEXT,
|
|
44
|
+
decision TEXT NOT NULL CHECK(decision IN ('allowed','blocked','rate_limited')),
|
|
45
|
+
reason TEXT,
|
|
46
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_urlpolicy_decision ON url_policy_audit(decision);
|
|
49
|
+
`);
|
|
50
|
+
|
|
51
|
+
const _rate = new Map(); // actor → [ts]
|
|
52
|
+
|
|
53
|
+
function _hit(actor) {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const arr = (_rate.get(actor) || []).filter((t) => now - t < RATE_WINDOW_MS);
|
|
56
|
+
arr.push(now);
|
|
57
|
+
_rate.set(actor, arr);
|
|
58
|
+
return arr.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _audit(actor, url, decision, reason) {
|
|
62
|
+
try {
|
|
63
|
+
db.prepare(`INSERT INTO url_policy_audit (id, actor, url, decision, reason)
|
|
64
|
+
VALUES (?, ?, ?, ?, ?)`).run(
|
|
65
|
+
crypto.randomUUID(), actor || null, url || null, decision, reason || null);
|
|
66
|
+
} catch (_) { /* never block on audit failure */ }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} rawUrl
|
|
71
|
+
* @param {object} opts
|
|
72
|
+
* @param {string} [opts.actor] - IP, API key id, or site id
|
|
73
|
+
* @returns {{ ok:boolean, reason?:string, code?:string, parsed?:URL }}
|
|
74
|
+
*/
|
|
75
|
+
function check(rawUrl, opts = {}) {
|
|
76
|
+
const actor = opts.actor || 'anon';
|
|
77
|
+
|
|
78
|
+
if (typeof rawUrl !== 'string' || rawUrl.length === 0) {
|
|
79
|
+
_audit(actor, String(rawUrl).slice(0, 200), 'blocked', 'missing_url');
|
|
80
|
+
return { ok: false, reason: 'URL is required', code: 'MISSING_URL' };
|
|
81
|
+
}
|
|
82
|
+
if (rawUrl.length > 2048) {
|
|
83
|
+
_audit(actor, rawUrl.slice(0, 200), 'blocked', 'url_too_long');
|
|
84
|
+
return { ok: false, reason: 'URL exceeds 2048 characters', code: 'URL_TOO_LONG' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let parsed;
|
|
88
|
+
try { parsed = new URL(rawUrl); }
|
|
89
|
+
catch {
|
|
90
|
+
_audit(actor, rawUrl.slice(0, 200), 'blocked', 'invalid_url');
|
|
91
|
+
return { ok: false, reason: 'Invalid URL', code: 'INVALID_URL' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
95
|
+
_audit(actor, rawUrl, 'blocked', `scheme:${parsed.protocol}`);
|
|
96
|
+
return { ok: false, reason: `Scheme ${parsed.protocol} not allowed`, code: 'BAD_SCHEME' };
|
|
97
|
+
}
|
|
98
|
+
if (process.env.WAB_URL_POLICY_HTTPS_ONLY === '1' && parsed.protocol !== 'https:') {
|
|
99
|
+
_audit(actor, rawUrl, 'blocked', 'http_disallowed');
|
|
100
|
+
return { ok: false, reason: 'HTTPS required', code: 'HTTPS_REQUIRED' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const host = parsed.hostname.toLowerCase();
|
|
104
|
+
const deny = new Set([...DEFAULT_DENY_HOSTS, ..._envHosts()]);
|
|
105
|
+
if (deny.has(host)) {
|
|
106
|
+
_audit(actor, rawUrl, 'blocked', `host_denied:${host}`);
|
|
107
|
+
return { ok: false, reason: `Host ${host} is denied by policy`, code: 'HOST_DENIED' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (DEFAULT_DENY_PATH_RE.test(parsed.pathname)) {
|
|
111
|
+
_audit(actor, rawUrl, 'blocked', `path_denied:${parsed.pathname}`);
|
|
112
|
+
return { ok: false, reason: 'Path matches abuse pattern', code: 'PATH_DENIED' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const count = _hit(actor);
|
|
116
|
+
if (count > RATE_MAX) {
|
|
117
|
+
_audit(actor, rawUrl, 'rate_limited', `count:${count}`);
|
|
118
|
+
return { ok: false, reason: `Rate limit exceeded (${RATE_MAX} URLs/min per actor)`, code: 'RATE_LIMITED' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_audit(actor, rawUrl, 'allowed', null);
|
|
122
|
+
return { ok: true, parsed };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getRecentAudits(limit = 100, decision) {
|
|
126
|
+
if (decision) {
|
|
127
|
+
return db.prepare(`SELECT * FROM url_policy_audit WHERE decision = ? ORDER BY rowid DESC LIMIT ?`).all(decision, limit);
|
|
128
|
+
}
|
|
129
|
+
return db.prepare(`SELECT * FROM url_policy_audit ORDER BY rowid DESC LIMIT ?`).all(limit);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function actorFromReq(req) {
|
|
133
|
+
return (req.wabAuth && req.wabAuth.key_id) ||
|
|
134
|
+
(req.user && req.user.id) ||
|
|
135
|
+
req.ip ||
|
|
136
|
+
'anon';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { check, getRecentAudits, actorFromReq, RATE_MAX };
|