claw-dashboard 1.8.4 → 2.0.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/.c8rc.json +38 -0
- package/.dockerignore +68 -0
- package/.github/dependabot.yml +45 -0
- package/.github/pull_request_template.md +39 -0
- package/.github/workflows/ci.yml +147 -0
- package/.github/workflows/release.yml +40 -0
- package/.github/workflows/security.yml +84 -0
- package/.husky/pre-commit +1 -0
- package/.planning/codebase/ARCHITECTURE.md +206 -0
- package/.planning/codebase/INTEGRATIONS.md +150 -0
- package/.planning/codebase/STACK.md +122 -0
- package/.planning/codebase/STRUCTURE.md +201 -0
- package/CHANGELOG.md +293 -0
- package/CONTRIBUTING.md +378 -0
- package/Dockerfile +54 -0
- package/FEATURES.md +195 -21
- package/README.md +83 -6
- package/TODO.md +28 -0
- package/build-cjs.js +127 -0
- package/cjs-shim.js +13 -0
- package/dist/clawdash +1729 -0
- package/dist/clawdash.meta.json +2236 -0
- package/dist/widgets.cjs +6511 -0
- package/docker-compose.yml +67 -0
- package/docs/API.md +1050 -0
- package/docs/PLUGINS.md +1504 -0
- package/esbuild.config.js +158 -0
- package/eslint.config.js +56 -0
- package/examples/plugins/README.md +122 -0
- package/examples/plugins/api-status/index.js +294 -0
- package/examples/plugins/api-status/plugin.json +19 -0
- package/examples/plugins/hello-world/index.js +104 -0
- package/examples/plugins/hello-world/plugin.json +15 -0
- package/examples/plugins/system-metrics-chart/index.js +339 -0
- package/examples/plugins/system-metrics-chart/plugin.json +18 -0
- package/examples/plugins/weather-widget/index.js +136 -0
- package/examples/plugins/weather-widget/plugin.json +19 -0
- package/index.cjs +23005 -0
- package/index.js +5331 -512
- package/jest.config.js +11 -0
- package/man/clawdash.1 +276 -0
- package/package.json +54 -5
- package/schemas/plugin-manifest.json +128 -0
- package/scripts/release.js +595 -0
- package/src/alerts.js +693 -0
- package/src/auto-save.js +584 -0
- package/src/cache.js +390 -0
- package/src/checksum.js +146 -0
- package/src/cli/args.js +110 -0
- package/src/cli/export-schedule.js +423 -0
- package/src/cli/export-snapshot.js +138 -0
- package/src/cli/help.js +69 -0
- package/src/cli/import-snapshot.js +230 -0
- package/src/cli/index.js +14 -0
- package/src/cli/list-templates.js +69 -0
- package/src/cli/validate-config.js +76 -0
- package/src/cli/validate-plugin.js +187 -0
- package/src/cli/version.js +15 -0
- package/src/config-validator.js +586 -0
- package/src/config-watcher.js +401 -0
- package/src/config.js +684 -0
- package/src/container-detector.js +499 -0
- package/src/database.js +734 -0
- package/src/differential-render.js +327 -0
- package/src/errors.js +169 -0
- package/src/export-scheduler.js +581 -0
- package/src/gateway-manager.js +685 -0
- package/src/hints.js +371 -0
- package/src/loading-states.js +509 -0
- package/src/logger.js +185 -0
- package/src/memory-pressure.js +467 -0
- package/src/performance-monitor.js +374 -0
- package/src/plugin-errors.js +586 -0
- package/src/plugin-manifest-validator.js +219 -0
- package/src/plugin-reload.js +481 -0
- package/src/plugin-scaffold.js +1941 -0
- package/src/plugin-validator.js +284 -0
- package/src/retry.js +218 -0
- package/src/security.js +860 -0
- package/src/snapshot.js +372 -0
- package/src/splash.js +115 -0
- package/src/theme-selector.js +411 -0
- package/src/themes.js +874 -0
- package/src/transitions.js +534 -0
- package/src/types.d.ts +174 -0
- package/src/validation.js +926 -0
- package/src/web-server.js +885 -0
- package/src/widgets/builtin-widgets.js +1057 -0
- package/src/widgets/config-processor.js +401 -0
- package/src/widgets/dependency-resolver.js +596 -0
- package/src/widgets/index.js +79 -0
- package/src/widgets/plugin-api.js +845 -0
- package/src/widgets/widget-error-boundary.js +773 -0
- package/src/widgets/widget-error-isolation.js +551 -0
- package/src/widgets/widget-loader.js +1437 -0
- package/src/workers/system-worker.js +130 -0
- package/src/workers/worker-pool.js +633 -0
- package/tests/alerts.test.js +437 -0
- package/tests/auto-save.test.js +529 -0
- package/tests/cache.test.js +317 -0
- package/tests/cli.test.js +351 -0
- package/tests/command-palette.test.js +320 -0
- package/tests/config-processor.test.js +452 -0
- package/tests/config-validator.test.js +710 -0
- package/tests/config-watcher.test.js +594 -0
- package/tests/config.test.js +755 -0
- package/tests/database.test.js +438 -0
- package/tests/errors.test.js +189 -0
- package/tests/example-plugins.test.js +624 -0
- package/tests/integration.test.js +1321 -0
- package/tests/loading-states.test.js +300 -0
- package/tests/manifest-validation-on-load.test.js +671 -0
- package/tests/performance-monitor.test.js +190 -0
- package/tests/plugin-api-rate-limit.test.js +302 -0
- package/tests/plugin-errors.test.js +311 -0
- package/tests/plugin-lifecycle-e2e.test.js +1036 -0
- package/tests/plugin-reload.test.js +764 -0
- package/tests/rate-limiter.test.js +539 -0
- package/tests/retry.test.js +308 -0
- package/tests/security.test.js +411 -0
- package/tests/settings-modal.test.js +226 -0
- package/tests/theme-selector.test.js +57 -0
- package/tests/utils.js +242 -0
- package/tests/utils.test.js +317 -0
- package/tests/validate-plugin-cli.test.js +359 -0
- package/tests/validation.test.js +837 -0
- package/tests/web-server.test.js +646 -0
- package/tests/widget-arrange-mode.test.js +183 -0
- package/tests/widget-config-hot-reload.test.js +465 -0
- package/tests/widget-dependency.test.js +591 -0
- package/tests/widget-error-boundary.test.js +749 -0
- package/tests/widget-error-isolation.test.js +469 -0
- package/tests/widget-integration.test.js +1147 -0
- package/tests/widget-refresh-intervals.test.js +284 -0
- package/tests/worker-pool.test.js +522 -0
package/src/security.js
ADDED
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security utilities for file permissions and input sanitization
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import { WEB } from './config.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validate that a file path is safe (no null bytes, proper type)
|
|
12
|
+
* @param {string} filePath - Path to validate
|
|
13
|
+
* @returns {boolean} - True if path is valid
|
|
14
|
+
*/
|
|
15
|
+
function isValidPath(filePath) {
|
|
16
|
+
if (!filePath || typeof filePath !== 'string') return false;
|
|
17
|
+
// Check for null bytes which can indicate injection attempts
|
|
18
|
+
if (filePath.includes('\0')) return false;
|
|
19
|
+
// Check for valid length
|
|
20
|
+
if (filePath.length === 0 || filePath.length > 4096) return false;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if path is a regular file (not symlink) before chmod
|
|
26
|
+
* @param {string} filePath - Path to check
|
|
27
|
+
* @returns {Promise<boolean>} - True if safe to chmod
|
|
28
|
+
*/
|
|
29
|
+
async function isSafeToChmod(filePath) {
|
|
30
|
+
try {
|
|
31
|
+
const stats = await fs.promises.lstat(filePath);
|
|
32
|
+
// Only chmod regular files, not symlinks or directories
|
|
33
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Set secure file permissions (0600 - owner read/write only)
|
|
44
|
+
* @param {string} filePath - Path to the file
|
|
45
|
+
* @returns {Promise<boolean>} - True if successful, false on failure
|
|
46
|
+
*/
|
|
47
|
+
async function setSecurePermissions(filePath) {
|
|
48
|
+
if (!isValidPath(filePath)) {
|
|
49
|
+
console.error('Invalid file path provided for permission setting');
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if path is safe (not a symlink)
|
|
54
|
+
if (!await isSafeToChmod(filePath)) {
|
|
55
|
+
console.error(`Cannot set permissions on non-file path: ${filePath}`);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await fs.promises.chmod(filePath, 0o600);
|
|
61
|
+
return true;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// Graceful fallback - log but don't crash
|
|
64
|
+
console.error(`Failed to set permissions on ${filePath}: ${err.message}`);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if path is a regular file (synchronous version)
|
|
71
|
+
* @param {string} filePath - Path to check
|
|
72
|
+
* @returns {boolean} - True if safe to chmod
|
|
73
|
+
*/
|
|
74
|
+
function isSafeToChmodSync(filePath) {
|
|
75
|
+
try {
|
|
76
|
+
const stats = fs.lstatSync(filePath);
|
|
77
|
+
// Only chmod regular files, not symlinks or directories
|
|
78
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Set secure file permissions (synchronous version)
|
|
89
|
+
* @param {string} filePath - Path to the file
|
|
90
|
+
* @returns {boolean} - True if successful, false on failure
|
|
91
|
+
*/
|
|
92
|
+
function setSecurePermissionsSync(filePath) {
|
|
93
|
+
if (!isValidPath(filePath)) {
|
|
94
|
+
console.error('Invalid file path provided for permission setting');
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check if path is safe (not a symlink)
|
|
99
|
+
if (!isSafeToChmodSync(filePath)) {
|
|
100
|
+
console.error(`Cannot set permissions on non-file path: ${filePath}`);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
fs.chmodSync(filePath, 0o600);
|
|
106
|
+
return true;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// Graceful fallback - log but don't crash
|
|
109
|
+
console.error(`Failed to set permissions on ${filePath}: ${err.message}`);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// WIDGET CONFIG SANITIZATION
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Widget configuration validator class
|
|
120
|
+
* Provides sanitization and validation for user-provided widget configs
|
|
121
|
+
*/
|
|
122
|
+
class WidgetConfigValidator {
|
|
123
|
+
constructor(options = {}) {
|
|
124
|
+
this.maxStringLength = options.maxStringLength || 1000;
|
|
125
|
+
this.maxDepth = options.maxDepth || 10;
|
|
126
|
+
this.maxArrayLength = options.maxArrayLength || 100;
|
|
127
|
+
this.allowedTypes = options.allowedTypes || ['string', 'number', 'boolean', 'object', 'array', 'null'];
|
|
128
|
+
this.stripNullBytes = options.stripNullBytes !== false;
|
|
129
|
+
this.maxKeyLength = options.maxKeyLength || 100;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validate and sanitize a widget configuration
|
|
134
|
+
* @param {*} config - Raw configuration object
|
|
135
|
+
* @param {Object} schema - Optional schema to validate against
|
|
136
|
+
* @returns {Object} Sanitized configuration
|
|
137
|
+
*/
|
|
138
|
+
validate(config, schema = null) {
|
|
139
|
+
if (config === null || config === undefined) {
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (typeof config !== 'object') {
|
|
144
|
+
throw new Error('Widget config must be an object');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return this._sanitizeValue(config, 0, schema);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Internal sanitization method with depth tracking
|
|
152
|
+
* @private
|
|
153
|
+
*/
|
|
154
|
+
_sanitizeValue(value, depth, schema) {
|
|
155
|
+
if (depth > this.maxDepth) {
|
|
156
|
+
throw new Error(`Configuration exceeds maximum depth of ${this.maxDepth}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (value === null) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (value === undefined) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const type = Array.isArray(value) ? 'array' : typeof value;
|
|
168
|
+
|
|
169
|
+
// Type whitelist check
|
|
170
|
+
if (!this.allowedTypes.includes(type)) {
|
|
171
|
+
throw new Error(`Invalid type: ${type}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (type === 'string') {
|
|
175
|
+
return this._sanitizeString(value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (type === 'number') {
|
|
179
|
+
return this._sanitizeNumber(value);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (type === 'boolean') {
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (type === 'array') {
|
|
187
|
+
return this._sanitizeArray(value, depth, schema);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (type === 'object') {
|
|
191
|
+
return this._sanitizeObject(value, depth, schema);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Sanitize a string value
|
|
199
|
+
* @private
|
|
200
|
+
*/
|
|
201
|
+
_sanitizeString(str) {
|
|
202
|
+
if (typeof str !== 'string') {
|
|
203
|
+
return String(str);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Null byte stripping
|
|
207
|
+
if (this.stripNullBytes) {
|
|
208
|
+
str = str.replace(/\0/g, '');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Length limit
|
|
212
|
+
if (str.length > this.maxStringLength) {
|
|
213
|
+
str = str.substring(0, this.maxStringLength);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return str;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Sanitize a number value
|
|
221
|
+
* @private
|
|
222
|
+
*/
|
|
223
|
+
_sanitizeNumber(num) {
|
|
224
|
+
if (typeof num !== 'number') {
|
|
225
|
+
return NaN;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Reject NaN and Infinity for safety
|
|
229
|
+
if (Number.isNaN(num) || !Number.isFinite(num)) {
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return num;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Sanitize an array
|
|
238
|
+
* @private
|
|
239
|
+
*/
|
|
240
|
+
_sanitizeArray(arr, depth, schema) {
|
|
241
|
+
if (!Array.isArray(arr)) {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Length limit
|
|
246
|
+
if (arr.length > this.maxArrayLength) {
|
|
247
|
+
arr = arr.slice(0, this.maxArrayLength);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Get array item schema if available
|
|
251
|
+
const itemSchema = schema?.items;
|
|
252
|
+
|
|
253
|
+
return arr.map((item, index) => {
|
|
254
|
+
try {
|
|
255
|
+
return this._sanitizeValue(item, depth + 1, itemSchema);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
// Skip invalid array items
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Sanitize an object
|
|
265
|
+
* @private
|
|
266
|
+
*/
|
|
267
|
+
_sanitizeObject(obj, depth, schema) {
|
|
268
|
+
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
|
269
|
+
return {};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const sanitized = {};
|
|
273
|
+
const properties = schema?.properties || {};
|
|
274
|
+
const allowedKeys = schema ? new Set(Object.keys(properties)) : null;
|
|
275
|
+
|
|
276
|
+
for (const key of Object.keys(obj)) {
|
|
277
|
+
// Key length limit
|
|
278
|
+
if (key.length > this.maxKeyLength) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Skip non-whitelisted keys if schema provided
|
|
283
|
+
if (allowedKeys && !allowedKeys.has(key)) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const keySchema = properties?.[key];
|
|
289
|
+
sanitized[key] = this._sanitizeValue(obj[key], depth + 1, keySchema);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
// Use default or skip invalid values
|
|
292
|
+
const defaultValue = properties?.[key]?.default;
|
|
293
|
+
sanitized[key] = defaultValue !== undefined ? defaultValue : null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return sanitized;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Sanitize widget configuration (convenience function)
|
|
303
|
+
* @param {*} config - Raw configuration
|
|
304
|
+
* @param {Object} schema - Optional schema
|
|
305
|
+
* @returns {Object} Sanitized config
|
|
306
|
+
*/
|
|
307
|
+
function sanitizeWidgetConfig(config, schema = null) {
|
|
308
|
+
const validator = new WidgetConfigValidator();
|
|
309
|
+
return validator.validate(config, schema);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Validate widget configuration against a schema
|
|
314
|
+
* @param {*} config - Configuration to validate
|
|
315
|
+
* @param {Object} schema - Validation schema
|
|
316
|
+
* @returns {Object} Validation result { valid: boolean, errors: string[] }
|
|
317
|
+
*/
|
|
318
|
+
function validateWidgetConfig(config, schema) {
|
|
319
|
+
const errors = [];
|
|
320
|
+
const validator = new WidgetConfigValidator();
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
validator.validate(config, schema);
|
|
324
|
+
return { valid: true, errors: [] };
|
|
325
|
+
} catch (err) {
|
|
326
|
+
errors.push(err.message);
|
|
327
|
+
return { valid: false, errors };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ============================================================================
|
|
332
|
+
// PLUGIN PATH VALIDATION
|
|
333
|
+
// ============================================================================
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Validate a plugin path to prevent path traversal attacks
|
|
337
|
+
* Ensures the resolved path stays within allowed base directories
|
|
338
|
+
*
|
|
339
|
+
* @param {string} inputPath - The path to validate (can be relative or absolute)
|
|
340
|
+
* @param {Object} options - Validation options
|
|
341
|
+
* @param {string[]} options.allowedDirs - Array of allowed base directories (resolved paths must be within these)
|
|
342
|
+
* @param {boolean} options.allowAbsolute - Whether to allow absolute paths (default: false)
|
|
343
|
+
* @param {boolean} options.mustExist - Whether the path must exist (default: false)
|
|
344
|
+
* @param {string} options.expectedType - Expected file type: 'file', 'directory', or null for any
|
|
345
|
+
* @returns {Object} Validation result: { valid: boolean, path: string|null, error: string|null }
|
|
346
|
+
*/
|
|
347
|
+
function validatePluginPath(inputPath, options = {}) {
|
|
348
|
+
const { allowedDirs = [], allowAbsolute = false, mustExist = false, expectedType = null } = options;
|
|
349
|
+
|
|
350
|
+
// Basic validation
|
|
351
|
+
if (!inputPath || typeof inputPath !== 'string') {
|
|
352
|
+
return { valid: false, path: null, error: 'Path must be a non-empty string' };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Check for null bytes
|
|
356
|
+
if (inputPath.includes('\0')) {
|
|
357
|
+
return { valid: false, path: null, error: 'Path contains null bytes' };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Reject absolute paths unless explicitly allowed
|
|
361
|
+
if (path.isAbsolute(inputPath) && !allowAbsolute) {
|
|
362
|
+
return { valid: false, path: null, error: 'Absolute paths are not allowed' };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Check for obvious path traversal attempts in the input
|
|
366
|
+
const normalizedInput = path.normalize(inputPath);
|
|
367
|
+
|
|
368
|
+
// Reject any path that:
|
|
369
|
+
// 1. Starts with .. (going up from base)
|
|
370
|
+
// 2. Contains ../ anywhere
|
|
371
|
+
// 3. After normalizing, would escape the base directory
|
|
372
|
+
if (normalizedInput.startsWith('..')) {
|
|
373
|
+
return { valid: false, path: null, error: 'Path traversal detected' };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Check for ../ in the original input before normalization
|
|
377
|
+
if (inputPath.includes('../') || inputPath.includes('..\\')) {
|
|
378
|
+
return { valid: false, path: null, error: 'Path traversal detected' };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Validate directory/file name characters
|
|
382
|
+
// Allow: alphanumeric, hyphens, underscores, dots (for extensions)
|
|
383
|
+
const parts = inputPath.split(path.sep).filter(part => part.length > 0);
|
|
384
|
+
for (const part of parts) {
|
|
385
|
+
// Skip if it's just '.' or '..'
|
|
386
|
+
if (part === '.' || part === '..') {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Check for invalid characters - allow alphanumerics, hyphens, underscores, dots
|
|
391
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(part)) {
|
|
392
|
+
return { valid: false, path: null, error: `Invalid characters in path component: ${part}` };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Check for hidden files/directories (starting with .)
|
|
396
|
+
if (part.startsWith('.') && part !== '.' && part !== '..') {
|
|
397
|
+
// Allow specific hidden files like .gitkeep but not arbitrary ones
|
|
398
|
+
const allowedHidden = ['.gitkeep', '.gitignore', '.npmignore'];
|
|
399
|
+
if (!allowedHidden.includes(part)) {
|
|
400
|
+
return { valid: false, path: null, error: `Hidden files/directories are not allowed: ${part}` };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Resolve the full path if allowedDirs provided
|
|
406
|
+
let resolvedPath;
|
|
407
|
+
try {
|
|
408
|
+
// Resolve relative to first allowed dir, or just normalize
|
|
409
|
+
if (allowedDirs.length > 0) {
|
|
410
|
+
// Use first allowed dir as base for relative paths
|
|
411
|
+
const baseDir = allowedDirs[0];
|
|
412
|
+
resolvedPath = path.resolve(baseDir, inputPath);
|
|
413
|
+
} else {
|
|
414
|
+
resolvedPath = path.resolve(inputPath);
|
|
415
|
+
}
|
|
416
|
+
} catch (err) {
|
|
417
|
+
return { valid: false, path: null, error: `Failed to resolve path: ${err.message}` };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Verify the resolved path is within allowed directories
|
|
421
|
+
if (allowedDirs.length > 0) {
|
|
422
|
+
const isWithinAllowed = allowedDirs.some(allowedDir => {
|
|
423
|
+
// Ensure allowedDir ends with separator for proper prefix check
|
|
424
|
+
const normalizedAllowed = allowedDir.endsWith(path.sep) ? allowedDir : allowedDir + path.sep;
|
|
425
|
+
const normalizedResolved = resolvedPath.endsWith(path.sep) ? resolvedPath : resolvedPath + path.sep;
|
|
426
|
+
return normalizedResolved.startsWith(normalizedAllowed);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
if (!isWithinAllowed) {
|
|
430
|
+
return { valid: false, path: null, error: 'Path is outside allowed directories' };
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Check if path must exist
|
|
435
|
+
if (mustExist) {
|
|
436
|
+
try {
|
|
437
|
+
const stats = fs.statSync(resolvedPath);
|
|
438
|
+
|
|
439
|
+
if (expectedType === 'file' && !stats.isFile()) {
|
|
440
|
+
return { valid: false, path: null, error: 'Path exists but is not a file' };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (expectedType === 'directory' && !stats.isDirectory()) {
|
|
444
|
+
return { valid: false, path: null, error: 'Path exists but is not a directory' };
|
|
445
|
+
}
|
|
446
|
+
} catch (err) {
|
|
447
|
+
return { valid: false, path: null, error: `Path does not exist: ${resolvedPath}` };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Verify the resolved path didn't escape via symlinks
|
|
452
|
+
try {
|
|
453
|
+
const realPath = fs.realpathSync(resolvedPath);
|
|
454
|
+
if (allowedDirs.length > 0) {
|
|
455
|
+
// Resolve allowedDirs to their real paths for proper comparison
|
|
456
|
+
// (e.g., on macOS /var is a symlink to /private/var)
|
|
457
|
+
const realAllowedDirs = allowedDirs.map(allowedDir => {
|
|
458
|
+
try {
|
|
459
|
+
return fs.realpathSync(allowedDir);
|
|
460
|
+
} catch {
|
|
461
|
+
return allowedDir; // If realpath fails, use original path
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const isRealPathWithinAllowed = realAllowedDirs.some(realAllowedDir => {
|
|
466
|
+
const normalizedAllowed = realAllowedDir.endsWith(path.sep) ? realAllowedDir : realAllowedDir + path.sep;
|
|
467
|
+
const normalizedReal = realPath.endsWith(path.sep) ? realPath : realPath + path.sep;
|
|
468
|
+
return normalizedReal.startsWith(normalizedAllowed);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
if (!isRealPathWithinAllowed) {
|
|
472
|
+
return { valid: false, path: null, error: 'Path resolves outside allowed directories via symlink' };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
} catch (err) {
|
|
476
|
+
// realpathSync fails if path doesn't exist - that's ok if mustExist is false
|
|
477
|
+
if (mustExist) {
|
|
478
|
+
return { valid: false, path: null, error: `Failed to resolve real path: ${err.message}` };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { valid: true, path: resolvedPath, error: null };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Validate a plugin directory name
|
|
487
|
+
* Ensures the name is safe for use as a directory name
|
|
488
|
+
*
|
|
489
|
+
* @param {string} name - Directory name to validate
|
|
490
|
+
* @returns {Object} Validation result: { valid: boolean, error: string|null }
|
|
491
|
+
*/
|
|
492
|
+
function validatePluginName(name) {
|
|
493
|
+
if (!name || typeof name !== 'string') {
|
|
494
|
+
return { valid: false, error: 'Plugin name must be a non-empty string' };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Trim whitespace
|
|
498
|
+
const trimmed = name.trim();
|
|
499
|
+
|
|
500
|
+
if (trimmed.length === 0) {
|
|
501
|
+
return { valid: false, error: 'Plugin name cannot be empty' };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (trimmed.length > 100) {
|
|
505
|
+
return { valid: false, error: 'Plugin name too long (max 100 characters)' };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Reserved names that could cause issues - check these first
|
|
509
|
+
const reservedNames = ['node_modules', 'package.json', 'package-lock.json', '.git', '.hg', '.svn'];
|
|
510
|
+
if (reservedNames.includes(trimmed.toLowerCase())) {
|
|
511
|
+
return { valid: false, error: `Plugin name '${trimmed}' is reserved` };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Allow: alphanumeric, hyphens, underscores
|
|
515
|
+
// Must start with alphanumeric
|
|
516
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(trimmed)) {
|
|
517
|
+
return { valid: false, error: 'Plugin name must contain only alphanumeric characters, hyphens, and underscores, and must start with alphanumeric' };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return { valid: true, error: null };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ============================================================================
|
|
524
|
+
// API KEY AUTHENTICATION
|
|
525
|
+
// ============================================================================
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* API Key Authentication Manager
|
|
529
|
+
* Handles API key generation, validation, and revocation for web server security
|
|
530
|
+
*/
|
|
531
|
+
class ApiKeyAuth {
|
|
532
|
+
constructor(options = {}) {
|
|
533
|
+
this.keys = new Map(); // key -> { name, createdAt, lastUsed, usageCount }
|
|
534
|
+
this.revokedKeys = new Set(); // Set of revoked key hashes
|
|
535
|
+
this.failedAttempts = new Map(); // ip -> { count, firstAttempt, blockedUntil }
|
|
536
|
+
|
|
537
|
+
// Configuration
|
|
538
|
+
this.enabled = options.enabled ?? WEB.AUTH.ENABLED;
|
|
539
|
+
this.headerName = options.headerName ?? WEB.AUTH.HEADER_NAME;
|
|
540
|
+
this.scheme = options.scheme ?? WEB.AUTH.SCHEME;
|
|
541
|
+
this.keyPrefix = options.keyPrefix ?? WEB.AUTH.KEY_PREFIX;
|
|
542
|
+
this.keyLength = options.keyLength ?? WEB.AUTH.KEY_LENGTH;
|
|
543
|
+
this.maxKeys = options.maxKeys ?? WEB.AUTH.MAX_KEYS;
|
|
544
|
+
this.maxFailedAttempts = options.maxFailedAttempts ?? 5;
|
|
545
|
+
this.blockDurationMs = options.blockDurationMs ?? 60000;
|
|
546
|
+
|
|
547
|
+
// Regex for valid key format
|
|
548
|
+
const prefix = this.keyPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
549
|
+
this.keyPattern = new RegExp(`^${prefix}[a-zA-Z0-9]{${this.keyLength}}$`);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Generate a cryptographically secure API key
|
|
554
|
+
* @param {string} name - Human-readable name for the key
|
|
555
|
+
* @returns {Object} { key, id, name, createdAt } - Returns the full key (only shown once)
|
|
556
|
+
*/
|
|
557
|
+
generateKey(name) {
|
|
558
|
+
if (!name || typeof name !== 'string') {
|
|
559
|
+
throw new Error('API key name is required');
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (name.length < WEB.AUTH.KEY_NAME_MIN_LENGTH || name.length > WEB.AUTH.KEY_NAME_MAX_LENGTH) {
|
|
563
|
+
throw new Error(`Key name must be between ${WEB.AUTH.KEY_NAME_MIN_LENGTH} and ${WEB.AUTH.KEY_NAME_MAX_LENGTH} characters`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (this.keys.size >= this.maxKeys) {
|
|
567
|
+
throw new Error(`Maximum number of API keys (${this.maxKeys}) reached`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Generate cryptographically secure random key
|
|
571
|
+
const randomBytes = crypto.randomBytes(Math.ceil(this.keyLength / 2));
|
|
572
|
+
const randomPart = randomBytes.toString('hex').slice(0, this.keyLength);
|
|
573
|
+
const key = `${this.keyPrefix}${randomPart}`;
|
|
574
|
+
|
|
575
|
+
const keyData = {
|
|
576
|
+
id: crypto.randomUUID(),
|
|
577
|
+
name: name.trim(),
|
|
578
|
+
createdAt: new Date().toISOString(),
|
|
579
|
+
lastUsed: null,
|
|
580
|
+
usageCount: 0,
|
|
581
|
+
keyHash: this._hashKey(key),
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
this.keys.set(key, keyData);
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
key, // Full key - only returned once
|
|
588
|
+
id: keyData.id,
|
|
589
|
+
name: keyData.name,
|
|
590
|
+
createdAt: keyData.createdAt,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Hash a key for secure storage/comparison
|
|
596
|
+
* @private
|
|
597
|
+
* @param {string} key - The API key
|
|
598
|
+
* @returns {string} SHA-256 hash of the key
|
|
599
|
+
*/
|
|
600
|
+
_hashKey(key) {
|
|
601
|
+
return crypto.createHash('sha256').update(key).digest('hex');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Validate an API key format without checking existence
|
|
606
|
+
* @param {string} key - The API key to validate
|
|
607
|
+
* @returns {boolean} True if format is valid
|
|
608
|
+
*/
|
|
609
|
+
isValidKeyFormat(key) {
|
|
610
|
+
if (!key || typeof key !== 'string') {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
return this.keyPattern.test(key);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Check if an IP is currently blocked due to failed attempts
|
|
618
|
+
* @param {string} ip - Client IP address
|
|
619
|
+
* @returns {Object} { blocked: boolean, retryAfter?: number }
|
|
620
|
+
*/
|
|
621
|
+
isBlocked(ip) {
|
|
622
|
+
if (!ip) return { blocked: false };
|
|
623
|
+
|
|
624
|
+
const attemptData = this.failedAttempts.get(ip);
|
|
625
|
+
if (!attemptData) return { blocked: false };
|
|
626
|
+
|
|
627
|
+
const now = Date.now();
|
|
628
|
+
if (attemptData.blockedUntil && now < attemptData.blockedUntil) {
|
|
629
|
+
return {
|
|
630
|
+
blocked: true,
|
|
631
|
+
retryAfter: Math.ceil((attemptData.blockedUntil - now) / 1000),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Unblock if time has passed
|
|
636
|
+
if (attemptData.blockedUntil && now >= attemptData.blockedUntil) {
|
|
637
|
+
this.failedAttempts.delete(ip);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return { blocked: false };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Record a failed authentication attempt
|
|
645
|
+
* @private
|
|
646
|
+
* @param {string} ip - Client IP address
|
|
647
|
+
*/
|
|
648
|
+
_recordFailedAttempt(ip) {
|
|
649
|
+
if (!ip) return;
|
|
650
|
+
|
|
651
|
+
const now = Date.now();
|
|
652
|
+
let attemptData = this.failedAttempts.get(ip);
|
|
653
|
+
|
|
654
|
+
if (!attemptData) {
|
|
655
|
+
attemptData = { count: 0, firstAttempt: now, blockedUntil: null };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
attemptData.count++;
|
|
659
|
+
|
|
660
|
+
// Block IP if max attempts exceeded
|
|
661
|
+
if (attemptData.count >= this.maxFailedAttempts) {
|
|
662
|
+
attemptData.blockedUntil = now + this.blockDurationMs;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
this.failedAttempts.set(ip, attemptData);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Clear failed attempts for an IP (after successful auth)
|
|
670
|
+
* @private
|
|
671
|
+
* @param {string} ip - Client IP address
|
|
672
|
+
*/
|
|
673
|
+
_clearFailedAttempts(ip) {
|
|
674
|
+
if (ip) {
|
|
675
|
+
this.failedAttempts.delete(ip);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Extract API key from request headers
|
|
681
|
+
* @param {Object} headers - HTTP request headers
|
|
682
|
+
* @returns {string|null} Extracted API key or null
|
|
683
|
+
*/
|
|
684
|
+
extractKey(headers) {
|
|
685
|
+
if (!headers || typeof headers !== 'object') {
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Case-insensitive header lookup
|
|
690
|
+
const headerNameLower = this.headerName.toLowerCase();
|
|
691
|
+
const authHeader = Object.entries(headers).find(
|
|
692
|
+
([key]) => key.toLowerCase() === headerNameLower
|
|
693
|
+
)?.[1];
|
|
694
|
+
|
|
695
|
+
if (!authHeader) return null;
|
|
696
|
+
|
|
697
|
+
// Handle scheme-based auth (e.g., "Bearer cd_abc123...")
|
|
698
|
+
if (this.scheme) {
|
|
699
|
+
const schemeLower = this.scheme.toLowerCase();
|
|
700
|
+
const authLower = authHeader.toLowerCase();
|
|
701
|
+
|
|
702
|
+
if (authLower.startsWith(`${schemeLower} `)) {
|
|
703
|
+
return authHeader.slice(this.scheme.length + 1).trim();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Return header value as-is (for x-api-key style headers)
|
|
708
|
+
return authHeader;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Authenticate a request
|
|
713
|
+
* @param {Object} headers - HTTP request headers
|
|
714
|
+
* @param {string} ip - Client IP address
|
|
715
|
+
* @returns {Object} Authentication result { authenticated: boolean, keyId?: string, error?: string }
|
|
716
|
+
*/
|
|
717
|
+
authenticate(headers, ip) {
|
|
718
|
+
// Skip authentication if disabled
|
|
719
|
+
if (!this.enabled) {
|
|
720
|
+
return { authenticated: true };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Check if IP is blocked
|
|
724
|
+
const blockStatus = this.isBlocked(ip);
|
|
725
|
+
if (blockStatus.blocked) {
|
|
726
|
+
return {
|
|
727
|
+
authenticated: false,
|
|
728
|
+
error: `Too many failed attempts. Retry after ${blockStatus.retryAfter} seconds`,
|
|
729
|
+
code: 'AUTH_BLOCKED',
|
|
730
|
+
retryAfter: blockStatus.retryAfter,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Extract key from headers
|
|
735
|
+
const key = this.extractKey(headers);
|
|
736
|
+
|
|
737
|
+
if (!key) {
|
|
738
|
+
this._recordFailedAttempt(ip);
|
|
739
|
+
return {
|
|
740
|
+
authenticated: false,
|
|
741
|
+
error: 'Authentication required. Provide API key in header',
|
|
742
|
+
code: 'AUTH_REQUIRED',
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Validate key format
|
|
747
|
+
if (!this.isValidKeyFormat(key)) {
|
|
748
|
+
this._recordFailedAttempt(ip);
|
|
749
|
+
return {
|
|
750
|
+
authenticated: false,
|
|
751
|
+
error: 'Invalid API key format',
|
|
752
|
+
code: 'AUTH_INVALID_FORMAT',
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Check if key exists
|
|
757
|
+
const keyData = this.keys.get(key);
|
|
758
|
+
if (!keyData) {
|
|
759
|
+
this._recordFailedAttempt(ip);
|
|
760
|
+
return {
|
|
761
|
+
authenticated: false,
|
|
762
|
+
error: 'Invalid API key',
|
|
763
|
+
code: 'AUTH_INVALID_KEY',
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Check if key is revoked
|
|
768
|
+
if (this.revokedKeys.has(keyData.keyHash)) {
|
|
769
|
+
this._recordFailedAttempt(ip);
|
|
770
|
+
return {
|
|
771
|
+
authenticated: false,
|
|
772
|
+
error: 'API key has been revoked',
|
|
773
|
+
code: 'AUTH_REVOKED',
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Successful authentication
|
|
778
|
+
this._clearFailedAttempts(ip);
|
|
779
|
+
keyData.lastUsed = new Date().toISOString();
|
|
780
|
+
keyData.usageCount++;
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
authenticated: true,
|
|
784
|
+
keyId: keyData.id,
|
|
785
|
+
keyName: keyData.name,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Revoke an API key
|
|
791
|
+
* @param {string} keyId - The key ID to revoke
|
|
792
|
+
* @returns {boolean} True if key was found and revoked
|
|
793
|
+
*/
|
|
794
|
+
revokeKey(keyId) {
|
|
795
|
+
for (const [key, data] of this.keys.entries()) {
|
|
796
|
+
if (data.id === keyId) {
|
|
797
|
+
this.revokedKeys.add(data.keyHash);
|
|
798
|
+
this.keys.delete(key);
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* List all active API keys (without exposing the actual keys)
|
|
807
|
+
* @returns {Array} List of key metadata
|
|
808
|
+
*/
|
|
809
|
+
listKeys() {
|
|
810
|
+
return Array.from(this.keys.values()).map(data => ({
|
|
811
|
+
id: data.id,
|
|
812
|
+
name: data.name,
|
|
813
|
+
createdAt: data.createdAt,
|
|
814
|
+
lastUsed: data.lastUsed,
|
|
815
|
+
usageCount: data.usageCount,
|
|
816
|
+
}));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Get the number of active keys
|
|
821
|
+
* @returns {number} Number of active API keys
|
|
822
|
+
*/
|
|
823
|
+
getKeyCount() {
|
|
824
|
+
return this.keys.size;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Check if authentication is enabled
|
|
829
|
+
* @returns {boolean} True if authentication is enabled
|
|
830
|
+
*/
|
|
831
|
+
isEnabled() {
|
|
832
|
+
return this.enabled;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Enable authentication
|
|
837
|
+
*/
|
|
838
|
+
enable() {
|
|
839
|
+
this.enabled = true;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Disable authentication
|
|
844
|
+
*/
|
|
845
|
+
disable() {
|
|
846
|
+
this.enabled = false;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Clear all API keys and failed attempts
|
|
851
|
+
*/
|
|
852
|
+
clear() {
|
|
853
|
+
this.keys.clear();
|
|
854
|
+
this.revokedKeys.clear();
|
|
855
|
+
this.failedAttempts.clear();
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export { setSecurePermissions, setSecurePermissionsSync, isValidPath, isSafeToChmod, isSafeToChmodSync, sanitizeWidgetConfig, validateWidgetConfig, WidgetConfigValidator, validatePluginPath, validatePluginName, ApiKeyAuth };
|
|
860
|
+
export default { setSecurePermissions, setSecurePermissionsSync, isValidPath, isSafeToChmod, isSafeToChmodSync, sanitizeWidgetConfig, validateWidgetConfig, WidgetConfigValidator, validatePluginPath, validatePluginName, ApiKeyAuth };
|