mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
b16a7d7b77
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.
76 lines
3.9 KiB
TypeScript
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);
|
|
});
|
|
});
|