@bibobonking/opensol 3.0.1 → 3.0.2

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.
@@ -0,0 +1,237 @@
1
+ import { createReadStream } from 'node:fs'
2
+ import { stat } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+
5
+ const MIME = {
6
+ '.html': 'text/html; charset=utf-8',
7
+ '.js': 'text/javascript; charset=utf-8',
8
+ '.mjs': 'text/javascript; charset=utf-8',
9
+ '.css': 'text/css; charset=utf-8',
10
+ '.json': 'application/json; charset=utf-8',
11
+ '.svg': 'image/svg+xml',
12
+ '.png': 'image/png',
13
+ '.jpg': 'image/jpeg',
14
+ '.jpeg': 'image/jpeg',
15
+ '.webp': 'image/webp',
16
+ '.gif': 'image/gif',
17
+ '.ico': 'image/x-icon',
18
+ '.woff': 'font/woff',
19
+ '.woff2': 'font/woff2',
20
+ '.ttf': 'font/ttf',
21
+ '.map': 'application/json; charset=utf-8',
22
+ '.txt': 'text/plain; charset=utf-8',
23
+ '.md': 'text/markdown; charset=utf-8',
24
+ '.wasm': 'application/wasm',
25
+ '.csv': 'text/csv; charset=utf-8',
26
+ '.xml': 'application/xml; charset=utf-8',
27
+ '.avif': 'image/avif',
28
+ '.otf': 'font/otf',
29
+ '.mp3': 'audio/mpeg',
30
+ '.wav': 'audio/wav',
31
+ '.ogg': 'audio/ogg',
32
+ '.mp4': 'video/mp4',
33
+ '.webm': 'video/webm',
34
+ '.glb': 'model/gltf-binary',
35
+ '.gltf': 'model/gltf+json',
36
+ }
37
+
38
+ export function mimeFor(file) {
39
+ return MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream'
40
+ }
41
+
42
+ export function streamFile(req, res, absolute, size, headers = {}) {
43
+ res.writeHead(200, {
44
+ 'Content-Type': mimeFor(absolute),
45
+ 'Content-Length': size,
46
+ ...headers,
47
+ })
48
+ if (req.method === 'HEAD') {
49
+ res.end()
50
+ return
51
+ }
52
+ const stream = createReadStream(absolute)
53
+ stream.on('error', () => res.destroy())
54
+ stream.pipe(res)
55
+ }
56
+
57
+ export class HttpError extends Error {
58
+ constructor(status, message, code = '') {
59
+ super(message)
60
+ this.name = 'HttpError'
61
+ this.status = status
62
+ this.code = code
63
+ }
64
+ }
65
+
66
+ export function securityHeaders(res) {
67
+ res.setHeader('X-Content-Type-Options', 'nosniff')
68
+ res.setHeader('X-Frame-Options', 'DENY')
69
+ res.setHeader('Referrer-Policy', 'no-referrer')
70
+ res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
71
+ res.setHeader(
72
+ 'Content-Security-Policy',
73
+ [
74
+ "default-src 'self'",
75
+ // `blob:` lets a user-approved Safe plugin load as a module without unsafe-eval.
76
+ "script-src 'self' 'unsafe-inline' blob:",
77
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
78
+ "font-src 'self' https://fonts.gstatic.com",
79
+ "img-src 'self' data: blob:",
80
+ "connect-src 'self'",
81
+ "frame-ancestors 'none'",
82
+ "base-uri 'self'",
83
+ "form-action 'self'",
84
+ ].join('; '),
85
+ )
86
+ }
87
+
88
+ export function json(res, status, value, headers = {}) {
89
+ const body = Buffer.from(JSON.stringify(value))
90
+ res.writeHead(status, {
91
+ 'Content-Type': 'application/json; charset=utf-8',
92
+ 'Content-Length': body.length,
93
+ 'Cache-Control': 'no-store',
94
+ ...headers,
95
+ })
96
+ res.end(body)
97
+ }
98
+
99
+ export function sendError(res, error) {
100
+ if (res.headersSent) {
101
+ res.destroy()
102
+ return
103
+ }
104
+ const status = error instanceof HttpError ? error.status : 500
105
+ const message =
106
+ error instanceof HttpError
107
+ ? error.message
108
+ : error instanceof Error
109
+ ? error.message
110
+ : 'The daemon hit an unexpected error.'
111
+ json(res, status, {
112
+ error: {
113
+ message,
114
+ code: error instanceof HttpError ? error.code : 'daemon_error',
115
+ status,
116
+ },
117
+ })
118
+ }
119
+
120
+ export async function readBody(req, maxBytes = 8 * 1024 * 1024) {
121
+ const declared = Number(req.headers['content-length'] ?? 0)
122
+ if (Number.isFinite(declared) && declared > maxBytes) {
123
+ throw new HttpError(413, 'That request is too large.', 'request_too_large')
124
+ }
125
+ const chunks = []
126
+ let total = 0
127
+ for await (const chunk of req) {
128
+ total += chunk.length
129
+ if (total > maxBytes) {
130
+ throw new HttpError(413, 'That request is too large.', 'request_too_large')
131
+ }
132
+ chunks.push(chunk)
133
+ }
134
+ return Buffer.concat(chunks)
135
+ }
136
+
137
+ export async function readJson(req, maxBytes = 8 * 1024 * 1024) {
138
+ const body = await readBody(req, maxBytes)
139
+ if (body.length === 0) return {}
140
+ try {
141
+ return JSON.parse(body.toString('utf8'))
142
+ } catch {
143
+ throw new HttpError(400, 'The request body is not valid JSON.', 'invalid_json')
144
+ }
145
+ }
146
+
147
+ export function clientIp(req) {
148
+ return req.socket.remoteAddress ?? 'local'
149
+ }
150
+
151
+ export function cookieValue(req, name) {
152
+ const header = req.headers.cookie
153
+ if (!header) return ''
154
+ for (const part of header.split(';')) {
155
+ const index = part.indexOf('=')
156
+ if (index === -1) continue
157
+ if (part.slice(0, index).trim() === name) return decodeURIComponent(part.slice(index + 1).trim())
158
+ }
159
+ return ''
160
+ }
161
+
162
+ export function setCookie(res, name, value, options = {}) {
163
+ const parts = [`${name}=${encodeURIComponent(value)}`, `Path=${options.path ?? '/'}`]
164
+ if (options.httpOnly !== false) parts.push('HttpOnly')
165
+ parts.push(`SameSite=${options.sameSite ?? 'Strict'}`)
166
+ if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`)
167
+ if (options.secure) parts.push('Secure')
168
+ const existing = res.getHeader('Set-Cookie')
169
+ const next = Array.isArray(existing) ? [...existing, parts.join('; ')] : existing ? [String(existing), parts.join('; ')] : [parts.join('; ')]
170
+ res.setHeader('Set-Cookie', next)
171
+ }
172
+
173
+ export async function serveStatic(req, res, root, pathname) {
174
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
175
+ throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
176
+ }
177
+ let relative
178
+ try {
179
+ relative = decodeURIComponent(pathname)
180
+ } catch {
181
+ throw new HttpError(400, 'That path is not valid.', 'invalid_path')
182
+ }
183
+ if (relative.includes('\0')) throw new HttpError(400, 'That path is not valid.', 'invalid_path')
184
+ relative = relative.replace(/^\/+/, '')
185
+ const candidate = path.resolve(root, relative)
186
+ const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`
187
+ if (candidate !== root && !candidate.startsWith(prefix)) {
188
+ throw new HttpError(403, 'That path is outside the app.', 'forbidden')
189
+ }
190
+
191
+ let target = candidate
192
+ let info
193
+ try {
194
+ info = await stat(target)
195
+ } catch {
196
+ info = null
197
+ }
198
+ if (info?.isDirectory()) {
199
+ target = path.join(target, 'index.html')
200
+ info = await stat(target).catch(() => null)
201
+ }
202
+ if (!info?.isFile()) {
203
+ // App routes are client-side. Only fall back for navigation requests.
204
+ const accepts = String(req.headers.accept ?? '')
205
+ if (pathname.startsWith('/api/') || (!accepts.includes('text/html') && pathname !== '/')) {
206
+ throw new HttpError(404, 'Not found.', 'not_found')
207
+ }
208
+ target = path.join(root, 'index.html')
209
+ info = await stat(target).catch(() => null)
210
+ }
211
+ if (!info?.isFile()) {
212
+ throw new HttpError(503, 'OpenSol is not built yet. Run the remote build and place the output in dist/.', 'dist_missing')
213
+ }
214
+
215
+ const ext = path.extname(target).toLowerCase()
216
+ const immutable = target.includes(`${path.sep}assets${path.sep}`)
217
+ res.writeHead(200, {
218
+ 'Content-Type': MIME[ext] ?? 'application/octet-stream',
219
+ 'Content-Length': info.size,
220
+ 'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache',
221
+ })
222
+ if (req.method === 'HEAD') {
223
+ res.end()
224
+ return
225
+ }
226
+ const stream = createReadStream(target)
227
+ stream.on('error', () => res.destroy())
228
+ stream.pipe(res)
229
+ }
230
+
231
+ export function parseJson(value, fallback = null) {
232
+ try {
233
+ return JSON.parse(value)
234
+ } catch {
235
+ return fallback
236
+ }
237
+ }
@@ -0,0 +1,321 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { lookup } from 'node:dns/promises'
3
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
4
+ import https from 'node:https'
5
+ import { isIP } from 'node:net'
6
+ import path from 'node:path'
7
+ import { HttpError } from './http.mjs'
8
+ import { DATA_DIR } from './store.mjs'
9
+ import { readPluginArchive } from '../../shared/plugin-package.mjs'
10
+
11
+ const PLUGINS_DIR = path.join(DATA_DIR, 'plugins')
12
+ const INDEX_FILE = path.join(PLUGINS_DIR, 'index.json')
13
+ const MAX_NETWORK_BODY = 64 * 1024
14
+ const MAX_NETWORK_RESPONSE = 1024 * 1024
15
+ const REQUEST_TIMEOUT_MS = 12_000
16
+ let writes = Promise.resolve()
17
+
18
+ async function ensurePluginsDir() {
19
+ await mkdir(PLUGINS_DIR, { recursive: true, mode: 0o700 })
20
+ }
21
+
22
+ async function readIndex() {
23
+ await ensurePluginsDir()
24
+ try {
25
+ const parsed = JSON.parse(await readFile(INDEX_FILE, 'utf8'))
26
+ return parsed && typeof parsed === 'object' && Array.isArray(parsed.plugins)
27
+ ? parsed.plugins
28
+ : []
29
+ } catch (error) {
30
+ if (error?.code === 'ENOENT') return []
31
+ if (error instanceof SyntaxError) throw new Error('OpenSol plugin index is damaged.')
32
+ throw error
33
+ }
34
+ }
35
+
36
+ async function saveIndex(plugins) {
37
+ const task = writes.then(async () => {
38
+ await ensurePluginsDir()
39
+ const temporary = `${INDEX_FILE}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
40
+ await writeFile(temporary, `${JSON.stringify({ plugins }, null, 2)}\n`, { mode: 0o600 })
41
+ await rename(temporary, INDEX_FILE)
42
+ })
43
+ writes = task.catch(() => undefined)
44
+ return task
45
+ }
46
+
47
+ function publicRecord(plugin) {
48
+ const { id, manifest, manifestHash, installedAt, safe, enabled, grants } = plugin
49
+ return { id, manifest, manifestHash, installedAt, safe, enabled, grants }
50
+ }
51
+
52
+ function locate(plugins, id) {
53
+ const plugin = plugins.find((item) => item.id === id)
54
+ if (!plugin) throw new HttpError(404, 'Plugin not found.', 'plugin_not_found')
55
+ return plugin
56
+ }
57
+
58
+ export async function listPlugins() {
59
+ return (await readIndex()).map(publicRecord)
60
+ }
61
+
62
+ export async function getPluginArchive(id) {
63
+ const plugins = await readIndex()
64
+ locate(plugins, id)
65
+ try {
66
+ return await readFile(path.join(PLUGINS_DIR, `${id}.plugin.json`))
67
+ } catch (error) {
68
+ if (error?.code === 'ENOENT') throw new HttpError(404, 'Plugin package is missing.', 'plugin_package_missing')
69
+ throw error
70
+ }
71
+ }
72
+
73
+ export async function installPlugin(bytes) {
74
+ let parsed
75
+ try {
76
+ parsed = readPluginArchive(bytes)
77
+ } catch (error) {
78
+ throw new HttpError(400, error instanceof Error ? error.message : 'Invalid plugin package.', 'invalid_plugin')
79
+ }
80
+ const plugins = await readIndex()
81
+ const manifestHash = createHash('sha256').update(JSON.stringify(parsed.manifest)).digest('hex')
82
+ const previous = plugins.find((item) => item.id === parsed.manifest.id)
83
+ const unchanged = previous?.manifestHash === manifestHash
84
+ const record = {
85
+ id: parsed.manifest.id,
86
+ manifest: parsed.manifest,
87
+ manifestHash,
88
+ installedAt: previous?.installedAt ?? new Date().toISOString(),
89
+ safe: unchanged ? previous.safe : false,
90
+ enabled: unchanged ? previous.enabled : false,
91
+ grants: unchanged
92
+ ? previous.grants
93
+ : { capabilities: [], domains: [] },
94
+ }
95
+ const archivePath = path.join(PLUGINS_DIR, `${record.id}.plugin.json`)
96
+ await writeFile(archivePath, bytes, { mode: 0o600 })
97
+ const next = previous
98
+ ? plugins.map((item) => (item.id === record.id ? record : item))
99
+ : [...plugins, record]
100
+ await saveIndex(next)
101
+ return publicRecord(record)
102
+ }
103
+
104
+ export async function setPluginGrants(id, grants) {
105
+ const plugins = await readIndex()
106
+ const plugin = locate(plugins, id)
107
+ if (
108
+ !grants ||
109
+ typeof grants !== 'object' ||
110
+ !Array.isArray(grants.capabilities) ||
111
+ !Array.isArray(grants.domains)
112
+ ) {
113
+ throw new HttpError(400, 'Plugin grants must list capabilities and domains.', 'invalid_plugin_grant')
114
+ }
115
+ const { capabilities, domains } = grants
116
+ if (
117
+ capabilities.some((value) => !plugin.manifest.capabilities.includes(value)) ||
118
+ domains.some((value) => !plugin.manifest.domains.includes(value))
119
+ ) {
120
+ throw new HttpError(400, 'A plugin cannot be granted undeclared permissions.', 'invalid_plugin_grant')
121
+ }
122
+ const updated = {
123
+ ...plugin,
124
+ grants: {
125
+ capabilities: [...new Set(capabilities)],
126
+ domains: [...new Set(domains)],
127
+ },
128
+ }
129
+ await saveIndex(plugins.map((item) => (item.id === id ? updated : item)))
130
+ return publicRecord(updated)
131
+ }
132
+
133
+ export async function setPluginSafe(id, safe) {
134
+ if (typeof safe !== 'boolean') throw new HttpError(400, 'Plugin trust must be explicitly true or false.', 'invalid_plugin_trust')
135
+ const plugins = await readIndex()
136
+ const plugin = locate(plugins, id)
137
+ const updated = { ...plugin, safe, enabled: safe ? plugin.enabled : false }
138
+ await saveIndex(plugins.map((item) => (item.id === id ? updated : item)))
139
+ return publicRecord(updated)
140
+ }
141
+
142
+ export async function setPluginEnabled(id, enabled) {
143
+ if (typeof enabled !== 'boolean') throw new HttpError(400, 'Plugin activation must be explicitly true or false.', 'invalid_plugin_activation')
144
+ const plugins = await readIndex()
145
+ const plugin = locate(plugins, id)
146
+ if (enabled && !plugin.safe) {
147
+ throw new HttpError(409, 'Review and trust the plugin before enabling it.', 'plugin_not_safe')
148
+ }
149
+ const updated = { ...plugin, enabled }
150
+ await saveIndex(plugins.map((item) => (item.id === id ? updated : item)))
151
+ return publicRecord(updated)
152
+ }
153
+
154
+ export async function uninstallPlugin(id) {
155
+ const plugins = await readIndex()
156
+ locate(plugins, id)
157
+ await rm(path.join(PLUGINS_DIR, `${id}.plugin.json`), { force: true })
158
+ await saveIndex(plugins.filter((item) => item.id !== id))
159
+ return { id, removed: true }
160
+ }
161
+
162
+ function privateAddress(address) {
163
+ if (isIP(address) === 4) {
164
+ const parts = address.split('.').map(Number)
165
+ return (
166
+ parts[0] === 0 ||
167
+ parts[0] === 10 ||
168
+ parts[0] === 127 ||
169
+ (parts[0] === 169 && parts[1] === 254) ||
170
+ (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
171
+ (parts[0] === 192 && parts[1] === 168) ||
172
+ parts[0] >= 224
173
+ )
174
+ }
175
+ if (isIP(address) === 6) {
176
+ const lower = address.toLowerCase()
177
+ return (
178
+ lower === '::' ||
179
+ lower === '::1' ||
180
+ lower.startsWith('fc') ||
181
+ lower.startsWith('fd') ||
182
+ lower.startsWith('fe80') ||
183
+ (lower.startsWith('::ffff:') && privateAddress(lower.slice(7)))
184
+ )
185
+ }
186
+ return true
187
+ }
188
+
189
+ async function addressesFor(hostname, lookupImpl) {
190
+ const literal = isIP(hostname)
191
+ const addresses = literal ? [{ address: hostname, family: literal }] : await lookupImpl(hostname, { all: true })
192
+ if (!addresses.length || addresses.some((entry) => privateAddress(entry.address))) {
193
+ throw new HttpError(403, 'Plugin network access to private hosts is blocked.', 'plugin_private_host')
194
+ }
195
+ return addresses
196
+ }
197
+
198
+ function httpsRequest(url, method, headers, body, addresses) {
199
+ return new Promise((resolve, reject) => {
200
+ const request = https.request(
201
+ {
202
+ protocol: 'https:',
203
+ hostname: url.hostname,
204
+ port: url.port || 443,
205
+ method,
206
+ path: `${url.pathname}${url.search}`,
207
+ headers,
208
+ timeout: REQUEST_TIMEOUT_MS,
209
+ lookup: (_hostname, options, callback) => {
210
+ if (options?.all) callback(null, addresses)
211
+ else callback(null, addresses[0].address, addresses[0].family)
212
+ },
213
+ },
214
+ (response) => {
215
+ const chunks = []
216
+ let total = 0
217
+ response.on('data', (chunk) => {
218
+ total += chunk.length
219
+ if (total > MAX_NETWORK_RESPONSE) {
220
+ request.destroy(new HttpError(413, 'Plugin network response is too large.', 'plugin_response_too_large'))
221
+ return
222
+ }
223
+ chunks.push(chunk)
224
+ })
225
+ response.on('end', () => resolve({
226
+ status: response.statusCode ?? 0,
227
+ headers: response.headers,
228
+ body: Buffer.concat(chunks),
229
+ }))
230
+ },
231
+ )
232
+ request.on('timeout', () => request.destroy(new HttpError(504, 'Plugin network request timed out.', 'plugin_request_timeout')))
233
+ request.on('error', reject)
234
+ if (body) request.write(body)
235
+ request.end()
236
+ })
237
+ }
238
+
239
+ export async function requestPluginNetwork(id, request, {
240
+ fetchImpl,
241
+ lookupImpl = lookup,
242
+ } = {}) {
243
+ const plugins = await readIndex()
244
+ const plugin = locate(plugins, id)
245
+ if (!plugin.safe || !plugin.enabled) throw new HttpError(403, 'Plugin network access requires an enabled Safe plugin.', 'plugin_disabled')
246
+ if (!plugin.grants.capabilities.includes('network.request')) {
247
+ throw new HttpError(403, 'Plugin has not been granted network access.', 'plugin_capability_denied')
248
+ }
249
+ let url
250
+ try {
251
+ url = new URL(String(request?.url ?? ''))
252
+ } catch {
253
+ throw new HttpError(400, 'Plugin URL is invalid.', 'plugin_bad_url')
254
+ }
255
+ if (
256
+ url.protocol !== 'https:' ||
257
+ url.username ||
258
+ url.password ||
259
+ url.port && url.port !== '443' ||
260
+ !plugin.manifest.domains.includes(url.hostname.toLowerCase()) ||
261
+ !plugin.grants.domains.includes(url.hostname.toLowerCase())
262
+ ) {
263
+ throw new HttpError(403, 'Plugin URL must use an explicitly granted HTTPS domain.', 'plugin_domain_denied')
264
+ }
265
+ const method = String(request.method ?? 'GET').toUpperCase()
266
+ if (!['GET', 'POST'].includes(method)) throw new HttpError(400, 'Plugin network method is not allowed.', 'plugin_bad_method')
267
+ const body = request.body === undefined ? undefined : String(request.body)
268
+ if (body && Buffer.byteLength(body) > MAX_NETWORK_BODY) {
269
+ throw new HttpError(413, 'Plugin network request body is too large.', 'plugin_request_too_large')
270
+ }
271
+ const headers = {}
272
+ for (const [key, value] of Object.entries(request.headers ?? {})) {
273
+ const lower = key.toLowerCase()
274
+ if (!['accept', 'content-type'].includes(lower) || typeof value !== 'string' || value.length > 200) {
275
+ throw new HttpError(400, 'Plugin request contains an unsupported header.', 'plugin_bad_header')
276
+ }
277
+ headers[lower] = value
278
+ }
279
+ const addresses = await addressesFor(url.hostname, lookupImpl)
280
+ const response = fetchImpl
281
+ ? await fetchImpl(url, {
282
+ method,
283
+ headers,
284
+ body,
285
+ redirect: 'manual',
286
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
287
+ })
288
+ : await httpsRequest(url, method, headers, body, addresses)
289
+ if (response.status >= 300 && response.status < 400) {
290
+ throw new HttpError(403, 'Plugin network redirects are blocked.', 'plugin_redirect_blocked')
291
+ }
292
+ if (response.status < 200 || response.status >= 300) {
293
+ throw new HttpError(502, `Plugin network host returned HTTP ${response.status}.`, 'plugin_request_failed')
294
+ }
295
+ const getHeader = (name) =>
296
+ typeof response.headers?.get === 'function'
297
+ ? response.headers.get(name)
298
+ : response.headers?.[name] ?? response.headers?.[name.toLowerCase()]
299
+ const contentType = getHeader('content-type')?.split(';')[0].trim().toLowerCase() ?? ''
300
+ if (!['application/json', 'text/plain', 'application/xml', 'text/xml'].includes(contentType)) {
301
+ throw new HttpError(415, 'Plugin response content type is not allowed.', 'plugin_content_type')
302
+ }
303
+ const disposition = getHeader('content-disposition') ?? ''
304
+ if (/attachment/i.test(disposition)) throw new HttpError(415, 'Plugin file downloads are blocked.', 'plugin_download_blocked')
305
+ const declaredLength = Number(getHeader('content-length') ?? 0)
306
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_NETWORK_RESPONSE) {
307
+ throw new HttpError(413, 'Plugin network response is too large.', 'plugin_response_too_large')
308
+ }
309
+ const bytes =
310
+ response.body instanceof Uint8Array
311
+ ? Buffer.from(response.body)
312
+ : Buffer.from(await response.arrayBuffer())
313
+ if (bytes.length > MAX_NETWORK_RESPONSE) {
314
+ throw new HttpError(413, 'Plugin network response is too large.', 'plugin_response_too_large')
315
+ }
316
+ return {
317
+ status: response.status,
318
+ contentType,
319
+ body: bytes.toString('utf8'),
320
+ }
321
+ }
@@ -0,0 +1,140 @@
1
+ import test, { beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm } from 'node:fs/promises'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { buildPluginArchive } from '../../shared/plugin-package.mjs'
7
+
8
+ const dataDir = await mkdtemp(path.join(os.tmpdir(), 'maxcode-plugin-test-'))
9
+ process.env.MAXCODE_DATA = dataDir
10
+ const plugins = await import('./plugins.mjs')
11
+
12
+ const baseManifest = {
13
+ id: 'test-plugin',
14
+ name: 'Test Plugin',
15
+ version: '1.0.0',
16
+ apiVersion: 1,
17
+ entry: 'plugin.js',
18
+ stylesheet: 'plugin.css',
19
+ capabilities: ['network.request'],
20
+ domains: ['api.example.test'],
21
+ }
22
+
23
+ function packageBytes(manifest = baseManifest) {
24
+ return buildPluginArchive({
25
+ manifest,
26
+ files: { 'plugin.js': 'api.registerCommand("sample", () => {})', 'plugin.css': '' },
27
+ })
28
+ }
29
+
30
+ async function installAndGrant(manifest = baseManifest) {
31
+ await plugins.installPlugin(packageBytes(manifest))
32
+ await plugins.setPluginGrants(manifest.id, {
33
+ capabilities: manifest.capabilities,
34
+ domains: manifest.domains,
35
+ })
36
+ await plugins.setPluginSafe(manifest.id, true)
37
+ await plugins.setPluginEnabled(manifest.id, true)
38
+ }
39
+
40
+ beforeEach(async () => {
41
+ for (const plugin of await plugins.listPlugins()) await plugins.uninstallPlugin(plugin.id)
42
+ })
43
+
44
+ test('installing a package starts disabled and not trusted', async () => {
45
+ const archive = packageBytes()
46
+ const installed = await plugins.installPlugin(archive)
47
+ assert.equal(installed.enabled, false)
48
+ assert.equal(installed.safe, false)
49
+ assert.deepEqual(installed.grants, { capabilities: [], domains: [] })
50
+ assert.equal((await plugins.listPlugins()).length, 1)
51
+ assert.deepEqual(await plugins.getPluginArchive(installed.id), Buffer.from(archive))
52
+ })
53
+
54
+ test('enabling an untrusted plugin or granting undeclared access is rejected', async () => {
55
+ await plugins.installPlugin(packageBytes())
56
+ await assert.rejects(plugins.setPluginEnabled('test-plugin', true), /trust|Safe/i)
57
+ await assert.rejects(
58
+ plugins.setPluginGrants('test-plugin', { capabilities: ['workspace.write'], domains: [] }),
59
+ /undeclared/i,
60
+ )
61
+ })
62
+
63
+ test('permission decisions reject malformed grant and activation values', async () => {
64
+ await plugins.installPlugin(packageBytes())
65
+ await assert.rejects(
66
+ plugins.setPluginGrants('test-plugin', { capabilities: 'network.request', domains: [] }),
67
+ /grant|capabilit/i,
68
+ )
69
+ await assert.rejects(plugins.setPluginSafe('test-plugin', 'true'), /trust|true or false/i)
70
+ await assert.rejects(plugins.setPluginEnabled('test-plugin', 'true'), /activation|true or false/i)
71
+ })
72
+
73
+ test('changing a manifest revokes prior trust and grants', async () => {
74
+ await installAndGrant()
75
+ const changed = { ...baseManifest, domains: ['api.example.test', 'new.example.test'] }
76
+ const result = await plugins.installPlugin(packageBytes(changed))
77
+ assert.equal(result.safe, false)
78
+ assert.equal(result.enabled, false)
79
+ assert.deepEqual(result.grants, { capabilities: [], domains: [] })
80
+ })
81
+
82
+ test('network requests require a separately granted exact domain', async () => {
83
+ await installAndGrant()
84
+ let called = false
85
+ await assert.rejects(
86
+ plugins.requestPluginNetwork('test-plugin', { url: 'https://other.example.test/data' }, {
87
+ fetchImpl: async () => {
88
+ called = true
89
+ return new Response('ok')
90
+ },
91
+ lookupImpl: async () => [{ address: '93.184.216.34' }],
92
+ }),
93
+ /domain|permission/i,
94
+ )
95
+ assert.equal(called, false)
96
+ })
97
+
98
+ test('network requests reject redirects instead of following them', async () => {
99
+ await installAndGrant()
100
+ await assert.rejects(
101
+ plugins.requestPluginNetwork('test-plugin', { url: 'https://api.example.test/data' }, {
102
+ fetchImpl: async () => new Response(null, { status: 302, headers: { location: 'https://other.example.test/' } }),
103
+ lookupImpl: async () => [{ address: '93.184.216.34' }],
104
+ }),
105
+ /redirect/i,
106
+ )
107
+ })
108
+
109
+ test('network responses reject executable and oversized content', async () => {
110
+ await installAndGrant()
111
+ await assert.rejects(
112
+ plugins.requestPluginNetwork('test-plugin', { url: 'https://api.example.test/file' }, {
113
+ fetchImpl: async () => new Response('alert(1)', { headers: { 'content-type': 'application/javascript' } }),
114
+ lookupImpl: async () => [{ address: '93.184.216.34' }],
115
+ }),
116
+ /content type/i,
117
+ )
118
+ await assert.rejects(
119
+ plugins.requestPluginNetwork('test-plugin', { url: 'https://api.example.test/large' }, {
120
+ fetchImpl: async () => new Response('x'.repeat(1_048_577), { headers: { 'content-type': 'text/plain' } }),
121
+ lookupImpl: async () => [{ address: '93.184.216.34' }],
122
+ }),
123
+ /large|size/i,
124
+ )
125
+ })
126
+
127
+ test('network requests reject private DNS results', async () => {
128
+ await installAndGrant()
129
+ await assert.rejects(
130
+ plugins.requestPluginNetwork('test-plugin', { url: 'https://api.example.test/data' }, {
131
+ fetchImpl: async () => new Response('unexpected'),
132
+ lookupImpl: async () => [{ address: '127.0.0.1' }],
133
+ }),
134
+ /private/i,
135
+ )
136
+ })
137
+
138
+ test.after(async () => {
139
+ await rm(dataDir, { recursive: true, force: true })
140
+ })