Files
TREK/server/tests/unit/plugins/manifest.test.ts
T
Maurice 64136484ee fix(plugins): security hardening from the PR #1415 audit
Remediates the findings from the adversarial audit (threat model: malicious
plugin author + malicious artifact). Highlights:

Critical
- proxy: force nosniff + Content-Disposition: attachment on every proxied reply
  and drop location/content-disposition + non-2xx from the passthrough, so a
  plugin can't serve an HTML document at TREK's origin (sandbox-escape → account
  takeover) or an open redirect.

High
- db:own runs synchronously in the host: cap the plugin DB (max_page_count) and
  row-cap query() via iterate() so a recursive CTE / huge blob can't stall the
  event loop, OOM, or exhaust the shared volume.
- supervisor: measure child RSS host-side (/proc/<pid>/statm) instead of trusting
  the spoofable heartbeat; add an activation timeout so a stuck onLoad can't hang
  activate() or peg a core unreaped.
- safe-extract: enforce entry-count + cumulative-size limits INSIDE readZip
  before inflating (decompression-bomb OOM).
- re-consent: activate() never widens granted permissions without explicit
  consent (409 CONSENT_REQUIRED); the row toggle + "Update All" route through the
  consent dialog, which now queues instead of overwriting.

Medium/low
- egress: gate dgram hostnames through the IP-vetting resolver; block the
  low-level socket escape (process.binding) + lock the wrapped prototypes;
  canonicalize IPv6 in isBlockedIp (hex-mapped/compressed metadata); reject
  degenerate `*.` / whole-TLD / spaced outbound hosts in the manifest + CSP.
- ws:broadcast is membership-gated to the acting user's trips / own connections;
  users.getById is scoped to users the acting user can see (no enumeration).
- safe-fetch streams + aborts at the byte cap (chunked codeload OOM); isPrivateIp
  reuses the canonicalizing check.
- native-scan throws instead of silently passing past its entry cap.
- SDK: dev serves binary assets as raw buffers + handles EADDRINUSE; manifest
  validator gains the reserved-id + outbound-host checks; wikis corrected.

Tests updated for the new membership-gated behaviour + regression tests added
(IPv6 canonicalization, wildcard hardening, outbound-host validation, ws/user
scoping). 234 plugin tests + 24 SDK tests green.
2026-07-04 19:11:23 +02:00

75 lines
3.8 KiB
TypeScript

/**
* trek-plugin.json validation (#plugins, M4). Rejects invalid ids/versions/types,
* unknown permissions, native modules, and http:outbound without egress.
*/
import { describe, it, expect } from 'vitest';
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
const base = { id: 'flight-tracker', name: 'Flight', version: '1.2.0', type: 'widget', apiVersion: 1 };
describe('parseManifest', () => {
it('parses a valid manifest with defaults', () => {
const m = parseManifest({ ...base });
expect(m.id).toBe('flight-tracker');
expect(m.icon).toBe('Blocks');
expect(m.nativeModules).toBe(false);
expect(m.permissions).toEqual([]);
});
it('derives minTrekVersion from the trek range', () => {
expect(parseManifest({ ...base, trek: '>=3.2.0 <4.0.0' }).minTrekVersion).toBe('3.2.0');
});
it('keeps known permissions + parses settings', () => {
const m = parseManifest({
...base,
permissions: ['db:own', 'http:outbound:api.x.com'],
egress: ['api.x.com'],
settings: [{ key: 'api_key', input_type: 'password', scope: 'instance', secret: true }, { bad: 1 }],
});
expect(m.permissions).toContain('db:own');
expect(m.settings).toHaveLength(1);
expect(m.settings[0]).toMatchObject({ key: 'api_key', secret: true, scope: 'instance' });
});
it('accepts a valid exact + wildcard outbound host', () => {
const m = parseManifest({ ...base, permissions: ['http:outbound:api.x.com', 'http:outbound:*.example.com'], egress: ['api.x.com', '*.example.com'] });
expect(m.permissions).toContain('http:outbound:*.example.com');
});
it.each([
['Bad-Id', { ...base, id: 'Bad-Id' }, /invalid id/],
['reserved id', { ...base, id: 'registry' }, /reserved id/],
['bad version', { ...base, version: '1.x' }, /invalid version/],
['bad type', { ...base, type: 'thing' }, /invalid type/],
['native', { ...base, nativeModules: true }, /native modules/],
['unknown perm', { ...base, permissions: ['fs:read'] }, /unknown permission/],
['outbound no egress', { ...base, permissions: ['http:outbound'] }, /egress\[\] is empty/],
['wildcard egress', { ...base, permissions: ['http:outbound'], egress: ['*'] }, /bare "\*"/],
['allow-all outbound host', { ...base, permissions: ['http:outbound:*'], egress: ['x.com'] }, /invalid http:outbound host/],
['degenerate wildcard host', { ...base, permissions: ['http:outbound:*.'], egress: ['x.com'] }, /invalid http:outbound host/],
['whole-TLD outbound host', { ...base, permissions: ['http:outbound:*.com'], egress: ['x.com'] }, /invalid http:outbound host/],
['host with a space', { ...base, permissions: ['http:outbound:legit.com x'], egress: ['legit.com'] }, /invalid http:outbound host/],
['bad egress host', { ...base, permissions: ['http:outbound:api.x.com'], egress: ['api.x.com', 'no spaces here'] }, /invalid egress host/],
['not an object', 'nope', /not an object/],
['missing name', { id: 'x-plugin', version: '1.0.0', type: 'page' }, /missing\/invalid "name"/],
])('rejects: %s', (_label, input, re) => {
expect(() => parseManifest(input)).toThrow(ManifestError);
expect(() => parseManifest(input)).toThrow(re as RegExp);
});
});
describe('parseManifest capabilities', () => {
it('parses a hero widget slot and defaults to sidebar', () => {
const hero = parseManifest({ ...base, capabilities: { widget: { slot: 'hero', title: 'T' } } });
expect(hero.capabilities.widget?.slot).toBe('hero');
const plain = parseManifest({ ...base, capabilities: { widget: {} } });
expect(plain.capabilities.widget?.slot).toBe('sidebar');
expect(parseManifest(base).capabilities).toEqual({});
});
it('rejects an unknown widget slot', () => {
expect(() => parseManifest({ ...base, capabilities: { widget: { slot: 'floating' } } })).toThrow(ManifestError);
});
});