conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 });