path-list-security.test.mjs
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
9 days ago
| 1 | /** |
| 2 | * Tier 7 — SECURITY: no token leak, 404 not-leak, write gate, T5 refuse (KN-WORK-PATH-LIST-b). |
| 3 | * |
| 4 | * Security tier MUST fail against a pre-fix stub that (a) 403s out-of-scope get while 404 for |
| 5 | * missing, or (b) writes the store when PATH_WRITES_ENABLED is unset, or (c) includes a Bearer |
| 6 | * in the list JSON. |
| 7 | * |
| 8 | * @see docs/KN-WORK-PATH-LIST-FREEZE.md §7 |
| 9 | */ |
| 10 | import { describe, it, beforeEach, afterEach } from 'node:test'; |
| 11 | import assert from 'node:assert/strict'; |
| 12 | import fs from 'node:fs'; |
| 13 | import path from 'node:path'; |
| 14 | import { fileURLToPath } from 'node:url'; |
| 15 | import { getRepoRoot } from '../lib/repo-root.mjs'; |
| 16 | import { upsertLearningPath, listLearningPaths } from '../lib/path/path-store.mjs'; |
| 17 | import { handlePathGetRequest, handlePathListRequest } from '../lib/path/path-handlers.mjs'; |
| 18 | import { handlePathProposeRequest, applyApprovedPathProposal } from '../lib/path/path-write.mjs'; |
| 19 | import { validateNotePath } from '../lib/path/path-store.mjs'; |
| 20 | import { |
| 21 | ADMITTED_TASK_PROPOSAL_KINDS, |
| 22 | ADMITTED_FLOW_PROPOSAL_KINDS, |
| 23 | ADMITTED_MEDIA_PROPOSAL_KINDS, |
| 24 | personalSelfApplyRefusalReason, |
| 25 | } from '../lib/hub-proposal-personal-self-apply.mjs'; |
| 26 | import { isPathProposalForHostedApply } from '../lib/path/path-hosted-proposal.mjs'; |
| 27 | import { maybeApplyHostedPathAfterApprove } from '../hub/gateway/path-approve-hosted.mjs'; |
| 28 | |
| 29 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 30 | const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-path-list-security'); |
| 31 | |
| 32 | function samplePath(overrides = {}) { |
| 33 | return { |
| 34 | schema: 'knowtation.learning_path/v0', |
| 35 | path_id: 'path_sec0000000000001', |
| 36 | scope: 'org', |
| 37 | status: 'active', |
| 38 | title: 'Org path', |
| 39 | summary: 'Secret org summary', |
| 40 | goal: 'Org goal', |
| 41 | steps: [{ title: 'S', objective: 'O', source_document_ids: [] }], |
| 42 | current_step_index: 0, |
| 43 | step_count: 1, |
| 44 | next_step_title: 'S', |
| 45 | active_decisions: '', |
| 46 | workspace_id: 'ws-org', |
| 47 | note_path: null, |
| 48 | created: '2026-08-18T00:00:00Z', |
| 49 | updated: '2026-08-18T00:00:00Z', |
| 50 | ...overrides, |
| 51 | }; |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Pre-fix stubs the security suite must reject. |
| 56 | */ |
| 57 | function preFixGetStub(exists, inScope) { |
| 58 | if (!exists) return { status: 404, code: 'PATH_NOT_FOUND' }; |
| 59 | if (!inScope) return { status: 403, code: 'FORBIDDEN' }; |
| 60 | return { status: 200, code: 'OK' }; |
| 61 | } |
| 62 | |
| 63 | function preFixProposeWhenUnset() { |
| 64 | return { wroteStore: true, createdCanister: true }; |
| 65 | } |
| 66 | |
| 67 | function preFixListJson() { |
| 68 | return JSON.stringify({ paths: [], authorization: 'Bearer leaked-token' }); |
| 69 | } |
| 70 | |
| 71 | beforeEach(() => { |
| 72 | fs.rmSync(tmpRoot, { recursive: true, force: true }); |
| 73 | fs.mkdirSync(tmpRoot, { recursive: true }); |
| 74 | delete process.env.PATH_WRITES_ENABLED; |
| 75 | }); |
| 76 | |
| 77 | afterEach(() => { |
| 78 | fs.rmSync(tmpRoot, { recursive: true, force: true }); |
| 79 | delete process.env.PATH_WRITES_ENABLED; |
| 80 | }); |
| 81 | |
| 82 | describe('path-list security — pre-fix stubs fail the contract', () => { |
| 83 | it('(a) stub 403s out-of-scope while 404ing missing', () => { |
| 84 | assert.equal(preFixGetStub(false, true).status, 404); |
| 85 | assert.equal(preFixGetStub(true, false).status, 403); |
| 86 | assert.notEqual(preFixGetStub(true, false).status, preFixGetStub(false, true).status); |
| 87 | }); |
| 88 | |
| 89 | it('(b) stub writes the store when PATH_WRITES_ENABLED is unset', () => { |
| 90 | assert.equal(preFixProposeWhenUnset().wroteStore, true); |
| 91 | assert.equal(preFixProposeWhenUnset().createdCanister, true); |
| 92 | }); |
| 93 | |
| 94 | it('(c) stub includes a Bearer in the list JSON', () => { |
| 95 | assert.match(preFixListJson(), /Bearer/); |
| 96 | }); |
| 97 | }); |
| 98 | |
| 99 | describe('path-list security — real implementation', () => { |
| 100 | it('get unknown vs out-of-scope both 404 PATH_NOT_FOUND', () => { |
| 101 | const dataDir = path.join(tmpRoot, 'noleak'); |
| 102 | fs.mkdirSync(dataDir, { recursive: true }); |
| 103 | upsertLearningPath(dataDir, 'v', samplePath()); |
| 104 | const missing = handlePathGetRequest({ |
| 105 | dataDir, |
| 106 | vaultId: 'v', |
| 107 | pathId: 'path_doesnotexist0001', |
| 108 | cliScopes: ['personal'], |
| 109 | }); |
| 110 | const hidden = handlePathGetRequest({ |
| 111 | dataDir, |
| 112 | vaultId: 'v', |
| 113 | pathId: 'path_sec0000000000001', |
| 114 | cliScopes: ['personal'], |
| 115 | }); |
| 116 | assert.equal(missing.status, 404); |
| 117 | assert.equal(hidden.status, 404); |
| 118 | assert.equal(missing.code, 'PATH_NOT_FOUND'); |
| 119 | assert.equal(hidden.code, 'PATH_NOT_FOUND'); |
| 120 | }); |
| 121 | |
| 122 | it('create with foreign path_id rejected', async () => { |
| 123 | process.env.PATH_WRITES_ENABLED = '1'; |
| 124 | const dataDir = path.join(tmpRoot, 'foreign'); |
| 125 | fs.mkdirSync(dataDir, { recursive: true }); |
| 126 | const result = await handlePathProposeRequest({ |
| 127 | dataDir, |
| 128 | vaultId: 'v', |
| 129 | cliScopes: ['personal'], |
| 130 | body: { |
| 131 | path_id: 'path_foreignowned0001', |
| 132 | title: 'T', |
| 133 | summary: 'S', |
| 134 | goal: 'G', |
| 135 | steps: [{ title: 'S', objective: 'O', source_document_ids: [] }], |
| 136 | }, |
| 137 | createProposal: async () => { |
| 138 | throw new Error('must not create'); |
| 139 | }, |
| 140 | }); |
| 141 | assert.equal(result.code, 'PATH_ID_NOT_ALLOWED'); |
| 142 | }); |
| 143 | |
| 144 | it('note_path traversal 400', () => { |
| 145 | const bad = validateNotePath('../etc/passwd.md'); |
| 146 | assert.equal(bad.ok, false); |
| 147 | assert.equal(bad.code, 'PATH_NOTE_PATH_INVALID'); |
| 148 | }); |
| 149 | |
| 150 | it('write gate off → no store write, no canister create, apply-approved also 403', async () => { |
| 151 | const dataDir = path.join(tmpRoot, 'gate'); |
| 152 | fs.mkdirSync(dataDir, { recursive: true }); |
| 153 | let created = false; |
| 154 | const proposed = await handlePathProposeRequest({ |
| 155 | dataDir, |
| 156 | vaultId: 'v', |
| 157 | cliScopes: ['personal'], |
| 158 | body: { |
| 159 | title: 'T', |
| 160 | summary: 'S', |
| 161 | goal: 'G', |
| 162 | steps: [{ title: 'S', objective: 'O', source_document_ids: [] }], |
| 163 | }, |
| 164 | createProposal: async () => { |
| 165 | created = true; |
| 166 | return { proposal_id: 'should-not' }; |
| 167 | }, |
| 168 | }); |
| 169 | assert.equal(proposed.code, 'PATH_WRITES_DISABLED'); |
| 170 | assert.equal(created, false); |
| 171 | assert.equal(fs.existsSync(path.join(dataDir, 'hub_flow_store.json')), false); |
| 172 | assert.equal(fs.existsSync(path.join(dataDir, 'hub_proposals.json')), false); |
| 173 | |
| 174 | const apply = applyApprovedPathProposal(dataDir, { |
| 175 | vault_id: 'v', |
| 176 | body: JSON.stringify({ |
| 177 | proposal_kind: 'path_create', |
| 178 | path: samplePath({ path_id: 'path_shouldnotwrite01', scope: 'personal' }), |
| 179 | }), |
| 180 | }); |
| 181 | assert.equal(apply.ok, false); |
| 182 | assert.equal(apply.status, 403); |
| 183 | assert.equal(apply.code, 'PATH_WRITES_DISABLED'); |
| 184 | }); |
| 185 | |
| 186 | it('no token/Bearer in JSON of list/get', () => { |
| 187 | const dataDir = path.join(tmpRoot, 'json'); |
| 188 | fs.mkdirSync(dataDir, { recursive: true }); |
| 189 | upsertLearningPath( |
| 190 | dataDir, |
| 191 | 'v', |
| 192 | samplePath({ |
| 193 | path_id: 'path_personal00000001', |
| 194 | scope: 'personal', |
| 195 | title: 'Must not leak credentials', |
| 196 | }), |
| 197 | ); |
| 198 | const listed = handlePathListRequest({ dataDir, vaultId: 'v', cliScopes: ['personal'] }); |
| 199 | const dumped = JSON.stringify(listed.payload); |
| 200 | assert.equal(/Bearer/i.test(dumped), false); |
| 201 | assert.equal(/authorization/i.test(dumped), false); |
| 202 | assert.equal(/refresh/i.test(dumped), false); |
| 203 | }); |
| 204 | |
| 205 | it('path kinds are not in T5 admit lists; fingerprint is SELF_APPLY_NOT_ADMITTED', () => { |
| 206 | assert.equal(ADMITTED_TASK_PROPOSAL_KINDS.includes('path_create'), false); |
| 207 | assert.equal(ADMITTED_TASK_PROPOSAL_KINDS.includes('path_update'), false); |
| 208 | assert.equal(ADMITTED_TASK_PROPOSAL_KINDS.includes('path_archive'), false); |
| 209 | assert.equal(ADMITTED_FLOW_PROPOSAL_KINDS.includes('path_create'), false); |
| 210 | assert.equal(ADMITTED_MEDIA_PROPOSAL_KINDS.includes('path_create'), false); |
| 211 | |
| 212 | const reason = personalSelfApplyRefusalReason({ |
| 213 | proposal: { |
| 214 | source: 'learning_path', |
| 215 | review_queue: 'learning-path', |
| 216 | status: 'proposed', |
| 217 | path: 'meta/learning-paths/proposals/prop-1.json', |
| 218 | intent: 'scooling.review_tray.approve', |
| 219 | external_ref: 'scooling.path:abc', |
| 220 | body: JSON.stringify({ proposal_kind: 'path_create', path: { scope: 'personal' } }), |
| 221 | }, |
| 222 | hasVaultWrite: true, |
| 223 | partitionOwned: true, |
| 224 | role: 'editor', |
| 225 | authorActorId: 'user-1', |
| 226 | approverActorId: 'user-1', |
| 227 | sessionBound: true, |
| 228 | }); |
| 229 | assert.equal(reason, 'SELF_APPLY_NOT_ADMITTED'); |
| 230 | }); |
| 231 | |
| 232 | it('external_ref malformed 400 PATH_EXTERNAL_REF_INVALID', async () => { |
| 233 | process.env.PATH_WRITES_ENABLED = '1'; |
| 234 | const dataDir = path.join(tmpRoot, 'ext'); |
| 235 | fs.mkdirSync(dataDir, { recursive: true }); |
| 236 | const result = await handlePathProposeRequest({ |
| 237 | dataDir, |
| 238 | vaultId: 'v', |
| 239 | cliScopes: ['personal'], |
| 240 | body: { |
| 241 | title: 'T', |
| 242 | summary: 'S', |
| 243 | goal: 'G', |
| 244 | steps: [{ title: 'S', objective: 'O', source_document_ids: [] }], |
| 245 | external_ref: 'scooling.task:not-a-path', |
| 246 | }, |
| 247 | createProposal: async () => ({ proposal_id: 'nope' }), |
| 248 | }); |
| 249 | assert.equal(result.code, 'PATH_EXTERNAL_REF_INVALID'); |
| 250 | }); |
| 251 | |
| 252 | it('no writeNote from path modules', () => { |
| 253 | const dir = path.join(getRepoRoot(), 'lib/path'); |
| 254 | for (const name of fs.readdirSync(dir)) { |
| 255 | const src = fs.readFileSync(path.join(dir, name), 'utf8'); |
| 256 | assert.equal(src.includes('writeNote'), false, `${name} must not reference writeNote`); |
| 257 | } |
| 258 | }); |
| 259 | |
| 260 | it('hook returns null for task/capture/media proposals', async () => { |
| 261 | assert.equal( |
| 262 | isPathProposalForHostedApply({ |
| 263 | source: 'task', |
| 264 | review_queue: 'task-writes', |
| 265 | body: '{}', |
| 266 | }), |
| 267 | false, |
| 268 | ); |
| 269 | const skipped = await maybeApplyHostedPathAfterApprove({ |
| 270 | method: 'POST', |
| 271 | pathOnly: '/api/v1/notes', |
| 272 | upstreamStatus: 200, |
| 273 | canisterUrl: 'http://c', |
| 274 | bridgeUrl: 'http://b', |
| 275 | authorization: undefined, |
| 276 | vaultId: 'v', |
| 277 | effectiveUserId: 'u', |
| 278 | actorUserId: 'u', |
| 279 | canisterAuthHeaders: () => ({}), |
| 280 | }); |
| 281 | assert.equal(skipped, null); |
| 282 | }); |
| 283 | }); |
File History
1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
9 days ago