docs-oauth-connector-security.test.mjs
244 lines 9.1 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 import assert from 'node:assert/strict';
2 import fs from 'node:fs';
3 import os from 'node:os';
4 import path from 'node:path';
5 import test from 'node:test';
6
7 import { PKCE_METHOD_S256 } from '../lib/companion-oauth-pkce.mjs';
8 import { connectorForClient } from '../lib/docs/docs-connector-store.mjs';
9 import { DOCS_SYNC_REVIEW_QUEUE } from '../lib/docs/docs-import-propose.mjs';
10 import {
11 DOCS_OAUTH_GOOGLE_AUTHORIZED,
12 buildDocsGoogleAuthorizationUrl,
13 createFakeGoogleDriveClient,
14 handleBeginDocsConnector,
15 handleDocsConnectorCallback,
16 handleImportDocsConnectorFiles,
17 handleListDocsConnectorFiles,
18 handleSyncDocsConnector,
19 } from '../lib/docs/google-drive-connector.mjs';
20 import { oauthTokenVaultPath } from '../lib/docs/oauth-token-vault.mjs';
21 import {
22 DOCS_NOTION_HUB_KEY_AUTHORIZED,
23 handleBeginNotionConnector,
24 } from '../lib/docs/notion-hub-connector.mjs';
25 import {
26 matchesScoolingMediaFingerprint,
27 matchesScoolingTaskFingerprint,
28 matchesScoolingFlowFingerprint,
29 matchesScoolingReviewTrayFingerprint,
30 } from '../lib/hub-proposal-personal-self-apply.mjs';
31
32 const env = {
33 GOOGLE_DRIVE_OAUTH_CLIENT_ID: 'client',
34 GOOGLE_DRIVE_OAUTH_CLIENT_SECRET: 'credential',
35 KNOWTATION_DOCS_OAUTH_SECRET: 'v'.repeat(32),
36 DOCS_OAUTH_REDIRECT_URI: 'https://hub.example/api/v1/docs/connectors/callback',
37 SCOOLING_RETURN_URL_ALLOWLIST: 'https://school.example/connect',
38 };
39
40 test('security: Drive override-off and Notion gate short-circuit before I/O', async () => {
41 assert.equal(DOCS_OAUTH_GOOGLE_AUTHORIZED, true);
42 assert.equal(DOCS_NOTION_HUB_KEY_AUTHORIZED, true);
43 let called = false;
44 const googleClient = new Proxy({}, { get: () => { called = true; throw new Error('network touched'); } });
45 for (const response of [
46 await handleListDocsConnectorFiles({ dataDir: '/denied', googleClient, authorizedOverride: false }),
47 await handleImportDocsConnectorFiles({ dataDir: '/denied', googleClient, authorizedOverride: false }),
48 await handleSyncDocsConnector({ dataDir: '/denied', googleClient, authorizedOverride: false }),
49 handleBeginNotionConnector({ dataDir: '/denied', body: { provider: 'notion' }, authorizedOverride: false }),
50 ]) {
51 assert.equal(response.status, 501);
52 assert.equal(response.code, 'NOT_AUTHORIZED');
53 }
54 assert.equal(called, false);
55 });
56
57 test('security: query injection, id injection, secrets, PKCE, allowlist, namespace, no T5', async () => {
58 const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kn-docs-sec-'));
59 const vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'kn-docs-sec-vault-'));
60
61 const connector = {
62 connector_id: 'conn_0123456789abcdef',
63 provider: 'google-drive',
64 display_name: 'Drive',
65 status: 'connected',
66 account_sub: 'private-sub',
67 oauth_ref: 'private-ref',
68 sync_cursor: 'private-cursor',
69 oauth_pending: { state: 'private-state' },
70 last_sync_error: 'none',
71 file_count: 0,
72 };
73 const json = JSON.stringify(connectorForClient(connector));
74 for (const secret of ['account_sub', 'oauth_ref', 'sync_cursor', 'oauth_pending', 'private-sub', 'private-ref', 'private-cursor']) {
75 assert.equal(json.includes(secret), false);
76 }
77
78 const driveSource = fs.readFileSync(new URL('../lib/docs/google-drive-connector.mjs', import.meta.url), 'utf8');
79 const notionSource = fs.readFileSync(new URL('../lib/docs/notion-hub-connector.mjs', import.meta.url), 'utf8');
80 const vaultSource = fs.readFileSync(new URL('../lib/docs/oauth-token-vault.mjs', import.meta.url), 'utf8');
81 assert.match(driveSource, /DOCS_OAUTH_GOOGLE_AUTHORIZED = true/);
82 assert.match(notionSource, /DOCS_NOTION_HUB_KEY_AUTHORIZED = true/);
83 assert.doesNotMatch(driveSource, /from ['"]\.\.\/write\.mjs['"]/);
84 assert.doesNotMatch(notionSource, /from ['"]\.\.\/write\.mjs['"]/);
85 assert.match(vaultSource, /docs_oauth/);
86 assert.doesNotMatch(vaultSource, /calendar_oauth/);
87 assert.equal(oauthTokenVaultPath(dataDir, 'conn_0123456789abcdef').includes(`${path.sep}docs_oauth${path.sep}`), true);
88 assert.equal(oauthTokenVaultPath(dataDir, 'conn_0123456789abcdef').includes('calendar_oauth'), false);
89
90 const authUrl = buildDocsGoogleAuthorizationUrl({
91 clientId: 'client',
92 redirectUri: env.DOCS_OAUTH_REDIRECT_URI,
93 state: 'state-value',
94 codeChallenge: 'challenge',
95 });
96 const parsed = new URL(authUrl);
97 assert.equal(parsed.searchParams.get('code_challenge_method'), PKCE_METHOD_S256);
98 assert.notEqual(parsed.searchParams.get('code_challenge_method'), 'plain');
99
100 const deniedReturn = handleBeginDocsConnector({
101 dataDir,
102 vaultId: 'v',
103 body: { provider: 'google-drive', return_url: 'https://evil.example/phish' },
104 env,
105 authorizedOverride: true,
106 });
107 assert.equal(deniedReturn.code, 'RETURN_URL_DENIED');
108
109 const begin = handleBeginDocsConnector({
110 dataDir,
111 vaultId: 'v',
112 body: { provider: 'google-drive', return_url: 'https://school.example/connect' },
113 env,
114 authorizedOverride: true,
115 now: 1_000,
116 });
117 assert.equal(begin.ok, true);
118 const state = new URL(begin.payload.authorization_url).searchParams.get('state');
119 const client = createFakeGoogleDriveClient({
120 files: [{ id: 'ok_file', name: 'Ok', mimeType: 'text/markdown', size: '3', modifiedTime: '2026-08-17T00:00:00Z' }],
121 contents: { ok_file: '# Ok' },
122 });
123 const okCb = await handleDocsConnectorCallback({
124 dataDir,
125 query: { state, code: 'code-1' },
126 googleClient: client,
127 env,
128 authorizedOverride: true,
129 now: 2_000,
130 });
131 assert.equal(okCb.ok, true);
132 assert.doesNotMatch(okCb.redirect, /refresh|access_token|code_verifier|credential/i);
133
134 // Replay same state → deny
135 const replay = await handleDocsConnectorCallback({
136 dataDir,
137 query: { state, code: 'code-2' },
138 googleClient: client,
139 env,
140 authorizedOverride: true,
141 now: 3_000,
142 });
143 assert.equal(replay.ok, false);
144 assert.match(replay.redirect ?? '', /reason=state_invalid/);
145
146 // Tampered state
147 const tamper = await handleDocsConnectorCallback({
148 dataDir,
149 query: { state: 'not-the-real-state', code: 'code-3' },
150 googleClient: client,
151 env,
152 authorizedOverride: true,
153 now: 4_000,
154 });
155 assert.equal(tamper.ok, false);
156 assert.match(tamper.redirect ?? '', /reason=state_invalid/);
157
158 // Expiry: new begin then callback past TTL
159 const begin2 = handleBeginDocsConnector({
160 dataDir,
161 vaultId: 'v2',
162 body: { provider: 'google-drive', return_url: 'https://school.example/connect' },
163 env,
164 authorizedOverride: true,
165 now: 10_000,
166 });
167 const state2 = new URL(begin2.payload.authorization_url).searchParams.get('state');
168 const expired = await handleDocsConnectorCallback({
169 dataDir,
170 query: { state: state2, code: 'code-4' },
171 googleClient: client,
172 env,
173 authorizedOverride: true,
174 now: 10_000 + 11 * 60_000,
175 });
176 assert.equal(expired.ok, false);
177 assert.match(expired.redirect ?? '', /reason=state_invalid/);
178
179 const qBad = await handleListDocsConnectorFiles({
180 dataDir,
181 vaultId: 'v',
182 connectorId: begin.payload.connector_id,
183 query: { q: "name contains 'x' or trashed=true" },
184 googleClient: client,
185 env,
186 authorizedOverride: true,
187 });
188 assert.equal(qBad.status, 400);
189 assert.equal(qBad.code, 'BAD_REQUEST');
190
191 const idBad = await handleImportDocsConnectorFiles({
192 dataDir,
193 vaultPath,
194 vaultId: 'v',
195 connectorId: begin.payload.connector_id,
196 body: { file_ids: ["../etc/passwd", "ok;drop"] },
197 googleClient: client,
198 env,
199 authorizedOverride: true,
200 });
201 assert.equal(idBad.status, 400);
202 assert.equal(idBad.code, 'BAD_REQUEST');
203
204 // Scooling-shaped POST import gdrive/notion is not this route (no source_type field accepted)
205 const shaped = await handleImportDocsConnectorFiles({
206 dataDir,
207 vaultPath,
208 vaultId: 'v',
209 connectorId: begin.payload.connector_id,
210 body: { source_type: 'gdrive', file_ids: ['ok_file'] },
211 googleClient: client,
212 env,
213 authorizedOverride: true,
214 });
215 assert.equal(shaped.status, 400);
216 assert.equal(shaped.code, 'BAD_REQUEST');
217
218 const notionBegin = handleBeginNotionConnector({
219 dataDir,
220 vaultId: 'vn',
221 body: { provider: 'notion' },
222 env: { NOTION_API_KEY: 'super-secret-notion-key' },
223 authorizedOverride: true,
224 });
225 const notionJson = JSON.stringify(notionBegin.payload);
226 assert.equal(notionJson.includes('super-secret-notion-key'), false);
227 assert.equal(notionJson.includes('NOTION_API_KEY'), false);
228
229 // docs-sync has no T5 fingerprint admission helpers
230 const fakeProposal = {
231 source: 'import',
232 review_queue: DOCS_SYNC_REVIEW_QUEUE,
233 path: 'imports/google-drive/ok_file.md',
234 intent: 'docs-sync import: Ok',
235 frontmatter: { source: 'google-drive', source_id: 'ok_file' },
236 };
237 assert.equal(matchesScoolingReviewTrayFingerprint(fakeProposal), false);
238 assert.equal(matchesScoolingTaskFingerprint(fakeProposal), false);
239 assert.equal(matchesScoolingMediaFingerprint(fakeProposal), false);
240 assert.equal(matchesScoolingFlowFingerprint(fakeProposal), false);
241 const selfApplySrc = fs.readFileSync(new URL('../lib/hub-proposal-personal-self-apply.mjs', import.meta.url), 'utf8');
242 assert.doesNotMatch(selfApplySrc, /docs-sync/);
243 assert.doesNotMatch(selfApplySrc, /google-drive/);
244 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago