first commit
This commit is contained in:
commit
ff482ebe23
251 files changed
+19126
No files matched your search
@@ -0,0 +1,209 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createApp } from '../apps/server/app.mjs';
|
||||
import { openStore } from '../apps/server/store.mjs';
|
||||
import { verifyPolicy } from '../packages/core/policy.mjs';
|
||||
import { generateKeyPair,exportJWK,SignJWT } from 'jose';
|
||||
import { createHash,randomUUID } from 'node:crypto';
|
||||
import {digest} from '../apps/server/store.mjs';
|
||||
import {adminRoutePermission,principalForGroups} from '../apps/server/admin-access.mjs';
|
||||
async function fixture(t,extra={}){
|
||||
const dir=mkdtempSync(join(tmpdir(),'safetynet-test-')),store=openStore(dir),adminToken='test-only-administrator-credential-123456789';
|
||||
const config={adminToken,publicUrl:'http://127.0.0.1:4310',secure:false,oidcAdminGroup:'admins',oidcGroupsClaim:'groups',...extra};
|
||||
const server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(resolve=>server.on('listening',resolve));
|
||||
const base=`http://127.0.0.1:${server.address().port}`;
|
||||
t.after(async()=>{await new Promise(resolve=>server.close(resolve));store.db.close();rmSync(dir,{recursive:true,force:true});});
|
||||
async function api(route,{body,token=adminToken,method=body?'POST':'GET',headers={}}={}){const response=await fetch(base+route,{method,headers:{...(token?{authorization:`Bearer ${token}`}:{ }),...(body?{'content-type':'application/json'}:{}),...headers},body:body?JSON.stringify(body):undefined});return {status:response.status,body:await response.json()};}
|
||||
return {api,store,base,config};
|
||||
}
|
||||
test('authenticated devices behind corporate NAT have separate quotas and fake tokens cannot bypass IP limits',async t=>{
|
||||
const f=await fixture(t),devices=[];
|
||||
for(const user of ['a','b']){const code=(await f.api('/api/admin/enrollments',{body:{user}})).body.code;devices.push((await f.api('/api/device/enroll',{body:{name:user,platform:'win32',code},token:null})).body);}
|
||||
for(let n=0;n<180;n++)for(const device of devices)assert.equal((await f.api('/api/device/heartbeat',{token:device.token,body:{policyVersion:1}})).status,200);
|
||||
let limited=false;for(let n=0;n<305;n++){const result=await f.api('/api/device/heartbeat',{token:'invalid-public-token-'+String(n).padStart(20,'0'),body:{policyVersion:1}});assert.ok([401,429].includes(result.status));if(result.status===429)limited=true;}
|
||||
assert.equal(limited,true);assert.equal((await f.api('/api/device/heartbeat',{token:devices[0].token,body:{policyVersion:1}})).status,200);assert.equal((await f.api('/api/admin/overview')).status,200);
|
||||
});
|
||||
test('administration requires auth and rejects cross-origin changes',async t=>{
|
||||
const {api}=await fixture(t);assert.equal((await api('/api/admin/overview',{token:null})).status,401);
|
||||
assert.equal((await api('/api/admin/enrollments',{body:{user:'alice'},headers:{origin:'https://evil.example'}})).status,403);
|
||||
assert.equal((await api('/api/admin/overview')).status,200);
|
||||
});
|
||||
test('enrollment codes are one-use, signed policy works and revocation denies access',async t=>{
|
||||
const {api}=await fixture(t);
|
||||
const code=(await api('/api/admin/enrollments',{body:{user:'alice'}})).body.code;
|
||||
const body={code,name:'Alice laptop',platform:'linux'};
|
||||
const enrollment=await api('/api/device/enroll',{body,token:null});assert.equal(enrollment.status,200);
|
||||
const d=enrollment.body;assert.equal(verifyPolicy(d.policy,d.publicKey).version,1);
|
||||
assert.equal((await api('/api/device/enroll',{body,token:null})).status,401);
|
||||
assert.equal((await api('/api/device/policy',{token:d.token})).status,200);
|
||||
assert.equal((await api('/api/device/heartbeat',{token:d.token,body:{policyVersion:1}})).status,200);
|
||||
assert.equal((await api(`/api/admin/devices/${d.id}/revoke`,{body:{}})).status,200);
|
||||
assert.equal((await api('/api/device/policy',{token:d.token})).status,401);
|
||||
});
|
||||
test('policy publication detects conflicts and records actor without content',async t=>{
|
||||
const {api}=await fixture(t);const original=(await api('/api/admin/overview')).body.policy;
|
||||
const p=structuredClone(original);p.version=2;p.brand.name='Acme Browser';p.proxy={mode:'fixed_servers',rules:'proxy.example:8080',bypass:'*.example'};
|
||||
assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,200);
|
||||
assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,409);
|
||||
const overview=(await api('/api/admin/overview')).body;assert.equal(overview.policy.brand.name,'Acme Browser');
|
||||
assert.equal(overview.events[0].kind,'policy.published');assert.equal(overview.events[0].metadata.actor,'bootstrap-admin');
|
||||
});
|
||||
test('administrator remotely changes signed settings and enrolled users cannot publish overrides',async t=>{
|
||||
const {api}=await fixture(t);
|
||||
const code=(await api('/api/admin/enrollments',{body:{user:'managed-user'}})).body.code;
|
||||
const device=(await api('/api/device/enroll',{token:null,body:{code,name:'Managed Windows',platform:'win32'}})).body;
|
||||
const p=(await api('/api/admin/overview')).body.policy;p.version++;
|
||||
p.browser.settings={visible:false,hiddenSections:['passwords','agents'],lockedSections:['appearance'],appearance:{mode:'dark',accent:'violet',tabMode:'compact'}};
|
||||
assert.equal((await api('/api/admin/policy',{method:'PUT',body:p,token:device.token})).status,401);
|
||||
assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,200);
|
||||
const delivered=verifyPolicy((await api('/api/device/policy',{token:device.token})).body,device.publicKey,2);
|
||||
assert.deepEqual(delivered.browser.settings,p.browser.settings);
|
||||
const invalid=structuredClone(p);invalid.version++;invalid.browser.settings.hiddenSections=['unknown'];
|
||||
assert.equal((await api('/api/admin/policy',{method:'PUT',body:invalid})).status,400);
|
||||
assert.equal((await api('/api/admin/overview')).body.policy.version,2);
|
||||
});
|
||||
test('policy teaching test blocks matching text and rejects arbitrary telemetry payloads',async t=>{
|
||||
const {api}=await fixture(t);const p=(await api('/api/admin/overview')).body.policy;
|
||||
p.dlp.rules=[{id:'secret',label:'Secret',kind:'literal',value:'Secret Roadmap',action:'block'}];
|
||||
const verdict=await api('/api/admin/policy/test',{body:{text:'Send Secret Roadmap to AI',policy:p}});assert.equal(verdict.body.decision,'block');
|
||||
const code=(await api('/api/admin/enrollments',{body:{user:'bob'}})).body.code;
|
||||
const d=(await api('/api/device/enroll',{token:null,body:{code,name:'Bob',platform:'win32'}})).body;
|
||||
assert.equal((await api('/api/device/events',{token:d.token,body:{kind:'dlp.block',policyVersion:1,prompt:'must not be logged'}})).status,400);
|
||||
assert.equal((await api('/api/device/ai',{token:d.token,body:{prompt:'hello'}})).status,409);
|
||||
});
|
||||
test('SSO remains unavailable until configured; callback cannot bypass state validation',async t=>{
|
||||
const {api,base}=await fixture(t);
|
||||
assert.equal((await api('/api/device/sso/start',{body:{},token:null})).status,409);
|
||||
const response=await fetch(`${base}/auth/callback?state=forged&code=forged`);assert.equal(response.status,400);
|
||||
});
|
||||
async function oidcFixture(t,groups,extra={}){
|
||||
const {privateKey,publicKey}=await generateKeyPair('RS256'),jwk={...await exportJWK(publicKey),kid:'test-key'};
|
||||
let nonce='',challenge='',badNonce=false;
|
||||
const oidcIssuer='https://id.example.test';
|
||||
const oidcFetch=async(url,options)=>{
|
||||
const u=String(url);
|
||||
if(u.endsWith('/.well-known/openid-configuration'))return Response.json({issuer:oidcIssuer,authorization_endpoint:oidcIssuer+'/authorize',token_endpoint:oidcIssuer+'/token',jwks_uri:oidcIssuer+'/keys'});
|
||||
if(u.endsWith('/keys'))return Response.json({keys:[jwk]});
|
||||
if(u.endsWith('/token')){
|
||||
const body=new URLSearchParams(options.body);assert.equal(createHash('sha256').update(body.get('code_verifier')).digest('base64url'),challenge);
|
||||
const jwt=await new SignJWT({nonce:badNonce?'wrong':nonce,groups,name:'Alice'}).setProtectedHeader({alg:'RS256',kid:'test-key'}).setIssuer(oidcIssuer).setAudience('test-client').setSubject('alice').setIssuedAt().setExpirationTime('5m').sign(privateKey);
|
||||
return Response.json({id_token:jwt});
|
||||
}throw Error('Unexpected OIDC request');
|
||||
};
|
||||
const f=await fixture(t,{oidcIssuer,oidcClientId:'test-client',oidcFetch,oidcDeviceGroup:'employees',onError:e=>{if(process.env.DEBUG_TEST_AUTH)console.error(e);},...extra});
|
||||
async function login(ticket=''){
|
||||
const r=await fetch(f.base+'/auth/login'+(ticket?'?ticket='+ticket:''),{redirect:'manual'});assert.equal(r.status,302);
|
||||
const url=new URL(r.headers.get('location'));nonce=url.searchParams.get('nonce');challenge=url.searchParams.get('code_challenge');
|
||||
const cookie=r.headers.get('set-cookie').split(';')[0];
|
||||
return fetch(f.base+`/auth/callback?state=${url.searchParams.get('state')}&code=test-code`,{redirect:'manual',headers:{cookie}});
|
||||
}
|
||||
return {...f,login,wrongNonce:()=>{badNonce=true;}};
|
||||
}
|
||||
test('OIDC validates signed identities and administrator group; session authorizes console',async t=>{
|
||||
const f=await oidcFixture(t,['admins','employees']);const callback=await f.login();assert.equal(callback.status,302);
|
||||
const cookie=callback.headers.getSetCookie().find(v=>v.startsWith('sn_session=')).split(';')[0];
|
||||
const r=await fetch(f.base+'/api/admin/overview',{headers:{cookie}});assert.equal(r.status,200);assert.equal((await r.json()).principal.sub,'alice');
|
||||
});
|
||||
test('OIDC rejects non-admin console access and incorrect nonce',async t=>{
|
||||
const f=await oidcFixture(t,['employees']);assert.equal((await f.login()).status,403);f.wrongNonce();assert.equal((await f.login()).status,500);
|
||||
});
|
||||
|
||||
test('OIDC signed management groups create operator and auditor sessions without policy publication rights',async t=>{
|
||||
for(const role of ['operator','auditor']){
|
||||
const f=await oidcFixture(t,[role+'s'],{oidcOperatorGroup:'operators',oidcAuditorGroup:'auditors'}),callback=await f.login();
|
||||
assert.equal(callback.status,302);
|
||||
const cookie=callback.headers.getSetCookie().find(v=>v.startsWith('sn_session=')).split(';')[0];
|
||||
const session=await fetch(f.base+'/api/admin/session',{headers:{cookie}});assert.equal(session.status,200);
|
||||
const principal=(await session.json()).principal;assert.equal(principal.role,role);assert.equal(principal.admin,false);assert.equal(principal.permissions.includes('policy.write'),false);
|
||||
const result=await f.api('/api/admin/policy',{token:null,method:'PUT',body:{version:2},headers:{cookie,origin:f.config.publicUrl}});assert.equal(result.status,403);
|
||||
assert.equal(f.store.get('policy').version,1);
|
||||
}
|
||||
});
|
||||
|
||||
test('auditor authorization denies every mutation, personal bookmark review and unknown routes before handlers',async t=>{
|
||||
const f=await fixture(t),credential='synthetic-auditor-session-credential-123456789';
|
||||
f.store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:'auditor',role:'auditor',admin:true,permissions:['policy.write','bookmarks.read']}));
|
||||
const headers={cookie:'sn_session='+credential,origin:f.config.publicUrl};
|
||||
for(const [method,path] of [
|
||||
['PUT','/api/admin/policy'],['PUT','/api/admin/security/blocklists'],['POST','/api/admin/enrollments'],['POST','/api/admin/devices/fake/revoke'],['PATCH','/api/admin/devices/fake/inspection'],
|
||||
['GET','/api/admin/devices/fake/bookmarks'],['PUT','/api/admin/devices/fake/bookmarks'],['POST','/api/admin/teaching'],['POST','/api/admin/teaching/fake/publish'],
|
||||
['POST','/api/admin/semantic/reference'],['POST','/api/admin/semantic/document-reference'],['POST','/api/admin/security/visual-reference'],['POST','/api/admin/integrations/icap/probe'],['GET','/api/admin/future-endpoint']
|
||||
])assert.equal((await f.api(path,{token:null,method,body:method==='GET'?undefined:{},headers})).status,403,path);
|
||||
const overview=await f.api('/api/admin/overview',{token:null,headers});assert.equal(overview.status,200);assert.equal(overview.body.principal.admin,false);assert.equal(overview.body.principal.permissions.includes('policy.write'),false);
|
||||
assert.equal((await fetch(f.base+'/api/admin/events/export?format=cef',{headers})).status,200);
|
||||
assert.equal(f.store.get('policy').version,1);assert.equal(f.store.db.prepare('SELECT COUNT(*) AS n FROM enrollments').get().n,0);
|
||||
});
|
||||
|
||||
test('cookie management requires same-origin mutation and expired/unknown roles cannot authenticate',async t=>{
|
||||
const f=await fixture(t),credential='synthetic-admin-session-credential-123456789';
|
||||
const put=(principal,expires=Date.now()+60000)=>f.store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(credential),expires,JSON.stringify(principal));
|
||||
put({sub:'legacy-admin',admin:true});const headers={cookie:'sn_session='+credential};
|
||||
assert.equal((await f.api('/api/admin/enrollments',{token:null,headers,body:{user:'fixture'}})).status,403);
|
||||
assert.equal((await f.api('/api/admin/enrollments',{token:null,headers:{...headers,origin:f.config.publicUrl},body:{user:'fixture'}})).status,200);
|
||||
assert.equal((await fetch(f.base+'/auth/logout',{method:'POST',headers})).status,403);
|
||||
put({sub:'unknown',role:'superadmin',admin:true});assert.equal((await f.api('/api/admin/overview',{token:null,headers})).status,401);
|
||||
put({sub:'expired',role:'admin'},Date.now()-1000);assert.equal((await f.api('/api/admin/overview',{token:null,headers})).status,401);
|
||||
assert.equal(adminRoutePermission('POST','/api/admin/new-dangerous-endpoint'),null);
|
||||
assert.equal(principalForGroups({sub:'u'},['auditors','operators','admins'],{oidcAdminGroup:'admins',oidcOperatorGroup:'operators',oidcAuditorGroup:'auditors'}).role,'admin');
|
||||
});
|
||||
|
||||
test('remote HTTPS management never accepts the shared bootstrap administrator token',async t=>{
|
||||
const f=await fixture(t,{publicUrl:'https://management.example.test',secure:true});
|
||||
assert.equal((await f.api('/api/admin/overview')).status,401);
|
||||
assert.equal((await f.api('/api/admin/enrollments',{body:{user:'unauthorized'},headers:{origin:f.config.publicUrl}})).status,401);
|
||||
const status=await fetch(f.base+'/api/auth/status');assert.equal((await status.json()).bootstrapAllowed,false);
|
||||
});
|
||||
|
||||
test('operator can enroll and revoke devices but cannot change policy-backed inspection settings',async t=>{
|
||||
const f=await fixture(t),credential='synthetic-operator-session-credential-123456789';
|
||||
f.store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:'operator',role:'operator'}));
|
||||
const headers={cookie:'sn_session='+credential,origin:f.config.publicUrl};
|
||||
const enrollment=await f.api('/api/admin/enrollments',{token:null,headers,body:{user:'operator-device'}});assert.equal(enrollment.status,200);
|
||||
const d=await f.api('/api/device/enroll',{token:null,body:{code:enrollment.body.code,name:'Operator enrolled laptop',platform:'win32'}});assert.equal(d.status,200);
|
||||
assert.equal((await f.api(`/api/admin/devices/${d.body.id}/inspection`,{token:null,headers,method:'PATCH',body:{expectedVersion:1,documentAnalysisEnabled:false}})).status,403);
|
||||
assert.equal((await f.api(`/api/admin/devices/${d.body.id}/revoke`,{token:null,headers,body:{}})).status,200);
|
||||
assert.equal((await f.api('/api/device/policy',{token:d.body.token})).status,401);
|
||||
assert.equal(f.store.get('policy').version,1);
|
||||
});
|
||||
|
||||
test('only administrators inventory opaque management sessions and revocation immediately denies existing cookies',async t=>{
|
||||
const f=await fixture(t),credentials={operator:'synthetic-operator-session-credential-123456789',auditor:'synthetic-auditor-session-credential-123456789'};
|
||||
for(const [role,credential] of Object.entries(credentials))f.store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:role,role}));
|
||||
for(const credential of Object.values(credentials))assert.equal((await f.api('/api/admin/sessions',{token:null,headers:{cookie:'sn_session='+credential}})).status,403);
|
||||
const inventory=await f.api('/api/admin/sessions');assert.equal(inventory.status,200);assert.equal(inventory.body.sessions.length,2);
|
||||
const target=inventory.body.sessions.find(s=>s.role==='auditor');assert.match(target.id,/^[0-9a-f-]{36}$/);assert.deepEqual(Object.keys(target).sort(),['expiresAt','id','name','role','subject']);
|
||||
assert.equal((await f.api('/api/admin/sessions')).body.sessions.find(s=>s.role==='auditor').id,target.id);
|
||||
const encoded=JSON.stringify(inventory.body);for(const credential of Object.values(credentials)){assert.equal(encoded.includes(credential),false);assert.equal(encoded.includes(digest(credential)),false);}
|
||||
assert.equal((await f.api(`/api/admin/sessions/${target.id}/revoke`,{token:null,headers:{cookie:'sn_session='+credentials.operator,origin:f.config.publicUrl},body:{}})).status,403);
|
||||
assert.equal((await f.api('/api/admin/overview',{token:null,headers:{cookie:'sn_session='+credentials.auditor}})).status,200);
|
||||
assert.equal((await f.api(`/api/admin/sessions/${target.id}/revoke`,{body:{}})).status,200);
|
||||
assert.equal((await f.api('/api/admin/overview',{token:null,headers:{cookie:'sn_session='+credentials.auditor}})).status,401);
|
||||
assert.equal((await f.api(`/api/admin/sessions/${target.id}/revoke`,{body:{}})).status,404);
|
||||
assert.equal(f.store.db.prepare('SELECT COUNT(*) AS n FROM management_session_handles').get().n,1);
|
||||
const event=f.store.db.prepare("SELECT metadata FROM events WHERE kind='management.session-revoked'").get();assert.deepEqual(JSON.parse(event.metadata),{actor:'bootstrap-admin',count:1});
|
||||
});
|
||||
test('SSO device ticket requires its polling secret and enrolls exactly once',async t=>{
|
||||
const f=await oidcFixture(t,['employees']);
|
||||
const ticket=(await f.api('/api/device/sso/start',{token:null,body:{}})).body;
|
||||
const body={id:ticket.id,secret:ticket.secret,name:'SSO laptop',platform:'darwin'};
|
||||
assert.equal((await f.api('/api/device/sso/poll',{token:null,body})).status,202);
|
||||
assert.equal((await f.login(ticket.id)).status,200);
|
||||
assert.equal((await f.api('/api/device/sso/poll',{token:null,body:{...body,secret:'wrong'}})).status,401);
|
||||
const result=await f.api('/api/device/sso/poll',{token:null,body});assert.equal(result.status,200);
|
||||
assert.equal((await f.api('/api/device/sso/poll',{token:null,body})).status,401);
|
||||
assert.equal(f.store.db.prepare('SELECT user FROM devices WHERE id=?').get(result.body.id).user,'alice');
|
||||
});
|
||||
test('device audit retries deduplicate and security exports preserve cursor identity',async t=>{
|
||||
const f=await fixture(t),code=(await f.api('/api/admin/enrollments',{body:{user:'alice'}})).body.code;
|
||||
const d=(await f.api('/api/device/enroll',{token:null,body:{code,name:'Alice',platform:'win32'}})).body;
|
||||
const event={eventId:randomUUID(),kind:'dlp.block',ruleId:'secret',policyVersion:1};
|
||||
for(let n=0;n<2;n++)assert.equal((await f.api('/api/device/events',{token:d.token,body:event})).status,200);
|
||||
assert.equal(f.store.db.prepare("SELECT count(*) AS n FROM events WHERE kind='dlp.block'").get().n,1);
|
||||
const response=await fetch(f.base+'/api/admin/events/export?format=ecs',{headers:{authorization:`Bearer ${f.config.adminToken}`}});
|
||||
const events=(await response.text()).trim().split('\n').map(v=>JSON.parse(v));const block=events.find(v=>v.event.action==='dlp.block');
|
||||
assert.deepEqual(block.event.type,['denied']);assert.equal(block.event.outcome,'success');assert.equal(block.rule.id,'secret');
|
||||
assert.ok(response.headers.get('x-safetynet-next-cursor'));assert.equal(JSON.stringify(events).includes('token_hash'),false);
|
||||
});
|
||||
Reference in new issue
Block a user