Files
TREK/server/tests/unit/plugins/manifest.test.ts
T
Maurice b16a7d7b77 fix(plugins): close the 4 PARTIAL findings + regressions from the fix-verify pass
A second adversarial pass over the first remediation found four findings only
partially closed and five issues the fixes themselves introduced. This closes
them:

Partial → closed
- re-consent gate keyed on `granted.length > 0`, so a plugin first activated with
  ZERO permissions (granted '[]') was treated as never-consented and a later
  widening was granted silently. Now discovery marks a never-consented plugin with
  granted_permissions '' and activate() gates on "ever consented" (any non-empty
  string, including '[]').
- db:own DoS: block WITH RECURSIVE outright (the one construct that spins the
  synchronous host unboundedly regardless of the size/row caps, via query OR exec).
- dgram: also wrap `new dgram.Socket(...)` (bypassed createSocket) to inject the
  IP-vetting lookup, and lock createSocket/Socket.
- frame self-navigation: documented as a bounded best-effort mitigation (inherent
  to sandboxed iframes; exposure is the plugin's own routes + already-held context,
  never the httpOnly cookie).

Regressions introduced by the first pass → fixed
- proxy: only real redirects (301/302/303/307/308) are gated, to a RELATIVE in-app
  Location (supports OAuth-callback bounce, blocks open redirect); 300/304 pass
  through; attachment only on non-redirects.
- supervisor: measure RSS via /proc/<pid>/status VmRSS (page-size independent);
  activation-timeout awaits kill() before disposing the db handle.
- manifest HOST_RE: allow single-label hosts (self-hoster sibling services) while
  keeping wildcards multi-label; mirrored in the SDK + frame CSP filter.

Regression tests added (re-consent incl. the '[]' case, WITH RECURSIVE + row cap,
single-label host). 236 plugin tests + 24 SDK tests green.
2026-07-04 19:11:23 +02:00

76 lines
3.9 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 exact, single-label (self-hoster sibling), and wildcard outbound hosts', () => {
const m = parseManifest({ ...base, permissions: ['http:outbound:api.x.com', 'http:outbound:*.example.com', 'http:outbound:redis'], egress: ['api.x.com', '*.example.com', 'redis'] });
expect(m.permissions).toContain('http:outbound:*.example.com');
expect(m.permissions).toContain('http:outbound:redis');
});
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);
});
});