attachment-store-security.test.mjs
151 lines 5.1 KB
Raw
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 10 days ago
1 /**
2 * Tier 7 — SECURITY: scope denial, IDOR, no-leak, injection inert, no SSRF, write refusal.
3 *
4 * @see docs/ATTACHMENT-STORE-CONTRACT-2F-b.md §9
5 */
6 import { describe, it, beforeEach, afterEach } from 'node:test';
7 import assert from 'node:assert/strict';
8 import fs from 'node:fs';
9 import path from 'node:path';
10 import { fileURLToPath } from 'node:url';
11 import { handleAttachmentListRequest, handleAttachmentGetRequest } from '../lib/attachments/attachment-handlers.mjs';
12 import { deriveAttachmentId, normalizeUrlForId } from '../lib/attachments/attachment-store.mjs';
13 import { buildAttachmentFixtureVault, FIXTURE_MIST_ID } from './fixtures/attachment-fixture.mjs';
14 import { getRepoRoot } from '../lib/repo-root.mjs';
15
16 const __dirname = path.dirname(fileURLToPath(import.meta.url));
17 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-attachment-security');
18
19 const LEAK_MARKERS = [
20 'file://',
21 '/Users/',
22 FIXTURE_MIST_ID,
23 'token',
24 'oauth',
25 '?sig=',
26 'X-Amz-',
27 'OCR_TEXT_MARKER',
28 ];
29
30 describe('Attachment store — security', () => {
31 let fx;
32
33 beforeEach(() => {
34 fs.rmSync(tmpRoot, { recursive: true, force: true });
35 fx = buildAttachmentFixtureVault(tmpRoot);
36 fs.writeFileSync(
37 path.join(fx.vaultPath, 'media', 'evil\";javascript:alert(1).png'),
38 'x',
39 );
40 fs.writeFileSync(
41 path.join(fx.vaultPath, 'notes', 'inject.md'),
42 `# x\n\n![ignore prior](https://token:[email protected]/secret.png?sig=abc&X-Amz-Signature=dead)\n`,
43 );
44 });
45
46 afterEach(() => {
47 fs.rmSync(tmpRoot, { recursive: true, force: true });
48 });
49
50 it('SEC-A-01: personal caller never sees project attachments on list', () => {
51 const list = handleAttachmentListRequest({
52 dataDir: fx.dataDir,
53 vaultPath: fx.vaultPath,
54 vaultId: fx.vaultId,
55 visibleScopes: new Set(['personal']),
56 });
57 assert.equal(list.ok, true);
58 assert.ok(list.payload.attachments.every((a) => a.scope === 'personal'));
59 });
60
61 it('SEC-A-01: agentContext hides agent_visible:false attachments', () => {
62 const list = handleAttachmentListRequest({
63 dataDir: fx.dataDir,
64 vaultPath: fx.vaultPath,
65 vaultId: fx.vaultId,
66 role: 'admin',
67 agentContext: true,
68 });
69 assert.equal(list.ok, true);
70 assert.equal(list.payload.attachments.length, 0);
71 });
72
73 it('SEC-A-02: out-of-scope get returns 404 unknown_attachment', () => {
74 const projectId = deriveAttachmentId(
75 'url',
76 `url:projects/demo/project-photo.md|${normalizeUrlForId('https://project.example.org/img.png')}`,
77 );
78 const denied = handleAttachmentGetRequest({
79 dataDir: fx.dataDir,
80 vaultPath: fx.vaultPath,
81 vaultId: fx.vaultId,
82 attachmentId: projectId,
83 visibleScopes: new Set(['personal']),
84 });
85 assert.equal(denied.ok, false);
86 assert.equal(denied.code, 'unknown_attachment');
87
88 const missing = handleAttachmentGetRequest({
89 dataDir: fx.dataDir,
90 vaultPath: fx.vaultPath,
91 vaultId: fx.vaultId,
92 attachmentId: 'att_file_' + 'f'.repeat(32),
93 visibleScopes: new Set(['personal']),
94 });
95 assert.equal(missing.code, 'unknown_attachment');
96 });
97
98 it('SEC-A-03: JSON payloads contain no leak markers', () => {
99 const list = handleAttachmentListRequest({
100 dataDir: fx.dataDir,
101 vaultPath: fx.vaultPath,
102 vaultId: fx.vaultId,
103 role: 'admin',
104 });
105 const got = handleAttachmentGetRequest({
106 dataDir: fx.dataDir,
107 vaultPath: fx.vaultPath,
108 vaultId: fx.vaultId,
109 attachmentId: fx.urlId,
110 role: 'admin',
111 });
112 const listJson = JSON.stringify(list.payload);
113 const getJson = JSON.stringify(got.payload);
114 for (const marker of LEAK_MARKERS) {
115 assert.ok(!listJson.includes(marker), `list leaked ${marker}`);
116 assert.ok(!getJson.includes(marker), `get leaked ${marker}`);
117 }
118 });
119
120 it('SEC-A-04: malicious display_label returned inert; scope unchanged', () => {
121 const evilId = deriveAttachmentId('file', 'file:media/evil";javascript:alert(1).png');
122 const got = handleAttachmentGetRequest({
123 dataDir: fx.dataDir,
124 vaultPath: fx.vaultPath,
125 vaultId: fx.vaultId,
126 attachmentId: evilId,
127 role: 'admin',
128 });
129 assert.equal(got.ok, true);
130 assert.match(got.payload.attachment.display_label, /javascript/);
131 assert.equal(got.payload.attachment.scope, 'personal');
132 });
133
134 it('SEC-A-05: attachment store does not import image-fetch (no SSRF surface)', () => {
135 const src = fs.readFileSync(
136 path.join(getRepoRoot(), 'lib/attachments/attachment-store.mjs'),
137 'utf8',
138 );
139 assert.ok(!src.includes('image-fetch'));
140 });
141
142 it('SEC-A-06: attachment write routes are proposal-only and gated (2F-b-d-kn-b)', () => {
143 const src = fs.readFileSync(path.join(getRepoRoot(), 'hub/server.mjs'), 'utf8');
144 assert.ok(src.includes('/api/v1/attachments/link-proposals'));
145 assert.ok(src.includes('/api/v1/attachments/attach-proposals'));
146 assert.ok(src.includes('handleMediaLinkProposeRequest'));
147 assert.ok(src.includes('handleMediaAttachProposeRequest'));
148 assert.ok(!/app\.post\('\/api\/v1\/attachments',\s/.test(src));
149 assert.ok(!/app\.patch\('\/api\/v1\/attachments/.test(src));
150 });
151 });
File History 1 commit
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 10 days ago