security.test.js (7845B)
1 // test/security.test.js - the authentication boundaries, as regressions 2 // 3 // Each test here stands for a specific way in that used to work. 4 5 import test from 'node:test'; 6 import assert from 'node:assert/strict'; 7 import { startHarness, apiError } from './helpers/harness.js'; 8 import { assertRepoUrl } from '../src/lib/projects.js'; 9 10 async function withHarness(options, fn) { 11 const h = await startHarness(options); 12 try { 13 await fn(h); 14 } finally { 15 await h.stop(); 16 } 17 } 18 19 const json = { 'content-type': 'application/json' }; 20 const PUSH = { sha: null, ref: 'refs/heads/main' }; 21 22 function push(h) { 23 return JSON.stringify({ ...PUSH, sha: h.sha }); 24 } 25 26 // --- starting a build --- 27 28 test('a trigger without a signature or a session is refused', async () => { 29 await withHarness({}, async (h) => { 30 const res = await h.app.inject({ 31 method: 'POST', 32 url: '/api/v1/projects/demo/trigger', 33 headers: json, 34 payload: push(h), 35 }); 36 assert.equal(res.statusCode, 401, res.body); 37 assert.equal(apiError(res).name, 'InvalidSignature'); 38 }); 39 }); 40 41 test('a project carrying no trigger secret is still not world-buildable', async () => { 42 await withHarness({}, async (h) => { 43 await h.services.projects.setTriggerSecret('demo', null); 44 45 for (const url of ['/api/v1/projects/demo/trigger', '/api/v1/projects/demo/jobs']) { 46 const res = await h.app.inject({ method: 'POST', url, headers: json, payload: push(h) }); 47 assert.equal(res.statusCode, 401, `${url}: ${res.body}`); 48 } 49 }); 50 }); 51 52 test('a valid webhook signature still starts a build', async () => { 53 await withHarness({}, async (h) => { 54 const body = push(h); 55 const res = await h.app.inject({ 56 method: 'POST', 57 url: '/api/v1/projects/demo/trigger', 58 headers: { ...json, 'x-hub-signature-256': h.sign(body) }, 59 payload: body, 60 }); 61 assert.equal(res.statusCode, 200, res.body); 62 assert.equal(res.json().data.status, 'created'); 63 await h.waitForJob(res.json().data.job_id); 64 }); 65 }); 66 67 test('an owner may trigger with a session and no signature', async () => { 68 await withHarness({ bootstrap: true }, async (h) => { 69 const { headers } = await h.login(); 70 const res = await h.app.inject({ 71 method: 'POST', 72 url: '/api/v1/projects/demo/trigger', 73 headers: { ...headers, ...json }, 74 payload: push(h), 75 }); 76 assert.equal(res.statusCode, 200, res.body); 77 await h.waitForJob(res.json().data.job_id); 78 }); 79 }); 80 81 // --- reading a build back --- 82 83 test('a trigger secret does not read a private job', async () => { 84 await withHarness({ visibility: 'private' }, async (h) => { 85 const body = push(h); 86 const created = await h.app.inject({ 87 method: 'POST', 88 url: '/api/v1/projects/demo/trigger', 89 headers: { ...json, 'x-hub-signature-256': h.sign(body) }, 90 payload: body, 91 }); 92 const jobId = created.json().data.job_id; 93 await h.waitForJob(jobId); 94 95 for (const url of [`/api/v1/jobs/${jobId}`, `/api/v1/projects/demo/jobs/${jobId}`]) { 96 const res = await h.app.inject({ 97 method: 'GET', 98 url, 99 headers: { ...json, 'x-hub-signature-256': h.sign('') }, 100 }); 101 assert.equal(res.statusCode, 404, `${url}: ${res.body}`); 102 } 103 }); 104 }); 105 106 test('listing the jobs of a private project does not confirm it exists', async () => { 107 await withHarness({ visibility: 'private', bootstrap: true }, async (h) => { 108 const missing = await h.app.inject({ method: 'GET', url: '/api/v1/projects/nope/jobs' }); 109 const hidden = await h.app.inject({ method: 'GET', url: '/api/v1/projects/demo/jobs' }); 110 111 assert.equal(hidden.statusCode, 404, hidden.body); 112 assert.equal(apiError(hidden).name, 'UnknownProject'); 113 assert.equal(hidden.statusCode, missing.statusCode); 114 assert.equal(apiError(hidden).name, apiError(missing).name); 115 116 const { headers } = await h.login(); 117 const seen = await h.app.inject({ method: 'GET', url: '/api/v1/projects/demo/jobs', headers }); 118 assert.equal(seen.statusCode, 200, seen.body); 119 }); 120 }); 121 122 // --- repo_url --- 123 124 test('assertRepoUrl refuses transports that are not a repository', async () => { 125 const refused = [ 126 "ext::sh -c 'id>/tmp/pwned'", 127 'file:///etc/passwd', 128 'file:///srv/git/other.git', 129 'http://169.254.169.254/latest/meta-data/', 130 '--upload-pack=/bin/sh', 131 '/srv/git/other.git', 132 '', 133 ]; 134 for (const url of refused) { 135 assert.throws(() => assertRepoUrl(url), undefined, `${JSON.stringify(url)} should be refused`); 136 } 137 138 const accepted = [ 139 'https://example.com/a/b.git', 140 'ssh://git@example.com/a/b.git', 141 'git://example.com/a/b.git', 142 'git@example.com:a/b.git', 143 ]; 144 for (const url of accepted) { 145 assert.equal(assertRepoUrl(url), url); 146 } 147 148 // The admin CLI clones from disk; the API never does. 149 assert.equal(assertRepoUrl('/srv/git/other.git', { allowLocalPath: true }), '/srv/git/other.git'); 150 }); 151 152 test('the api refuses a project whose repo_url is a command', async () => { 153 await withHarness({ bootstrap: true }, async (h) => { 154 const { headers } = await h.login(); 155 const res = await h.app.inject({ 156 method: 'POST', 157 url: '/api/v1/projects', 158 headers: { ...headers, ...json }, 159 payload: JSON.stringify({ id: 'evil', repo_url: "ext::sh -c 'id'" }), 160 }); 161 assert.equal(res.statusCode, 400, res.body); 162 }); 163 }); 164 165 test('a project is never created without a trigger secret', async () => { 166 await withHarness({ bootstrap: true }, async (h) => { 167 const { headers } = await h.login(); 168 const res = await h.app.inject({ 169 method: 'POST', 170 url: '/api/v1/projects', 171 headers: { ...headers, ...json }, 172 payload: JSON.stringify({ 173 id: 'nosecret', 174 repo_url: 'https://example.com/a/b.git', 175 trigger_secret: null, 176 }), 177 }); 178 assert.equal(res.statusCode, 201, res.body); 179 assert.equal(res.json().data.has_trigger_secret, true); 180 assert.ok(res.json().data.trigger_secret); 181 }); 182 }); 183 184 // --- passwords --- 185 186 test('changing your own password needs the current one', async () => { 187 await withHarness({ bootstrap: true }, async (h) => { 188 const { headers } = await h.login(); 189 190 const bare = await h.app.inject({ 191 method: 'PATCH', 192 url: '/api/v1/users/me', 193 headers: { ...headers, ...json }, 194 payload: JSON.stringify({ password: 'a-brand-new-password' }), 195 }); 196 assert.equal(bare.statusCode, 403, bare.body); 197 198 const wrong = await h.app.inject({ 199 method: 'PATCH', 200 url: '/api/v1/users/me', 201 headers: { ...headers, ...json }, 202 payload: JSON.stringify({ password: 'a-brand-new-password', current_password: 'nope' }), 203 }); 204 assert.equal(wrong.statusCode, 403, wrong.body); 205 206 const right = await h.app.inject({ 207 method: 'PATCH', 208 url: '/api/v1/users/me', 209 headers: { ...headers, ...json }, 210 payload: JSON.stringify({ 211 password: 'a-brand-new-password', 212 current_password: 'bootstrap-password', 213 }), 214 }); 215 assert.equal(right.statusCode, 200, right.body); 216 }); 217 }); 218 219 test('an administrator still resets someone else without their password', async () => { 220 await withHarness({ bootstrap: true }, async (h) => { 221 const { headers } = await h.login(); 222 223 const created = await h.app.inject({ 224 method: 'POST', 225 url: '/api/v1/users', 226 headers: { ...headers, ...json }, 227 payload: JSON.stringify({ username: 'someone', password: 'their-password' }), 228 }); 229 assert.equal(created.statusCode, 201, created.body); 230 231 const reset = await h.app.inject({ 232 method: 'PATCH', 233 url: `/api/v1/users/${created.json().data.id}`, 234 headers: { ...headers, ...json }, 235 payload: JSON.stringify({ password: 'an-imposed-password' }), 236 }); 237 assert.equal(reset.statusCode, 200, reset.body); 238 }); 239 });