Merge ngit external-access PR 79ca68c1 into combined UAT candidate
Preserve current maintenance/session guards, Firewall UI and existing catalogs. Retain scoped guest access, publishing journeys and local Blossom integration. Normalize Blossom/router memory units to supported quadlet suffixes. Validation: 108 dashboard tests, 10 gateway policy tests, strict source catalog check. Integrated isolated backend qualification remains required before main.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
// Run inside a disposable Blossom container with ONLY the synthetic profile below allowed.
|
||||
// No real identity, external server or public relay is used. Retains one fixture for lifecycle checks.
|
||||
import { finalizeEvent, getPublicKey } from 'nostr-tools';
|
||||
const base = 'http://127.0.0.1:3000';
|
||||
const key = new Uint8Array(32).fill(1);
|
||||
const other = new Uint8Array(32).fill(2);
|
||||
const body = '<!doctype html><script>throw new Error("must not execute")</script><p>Blossom qualification, synthetic data only.</p>';
|
||||
const bytes = new TextEncoder().encode(body);
|
||||
const hash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), x => x.toString(16).padStart(2,'0')).join('');
|
||||
function auth(action: string, secret=key, server='127.0.0.1', expires=300) {
|
||||
const now = Math.floor(Date.now()/1000);
|
||||
return 'Nostr ' + btoa(JSON.stringify(finalizeEvent({ kind:24242,created_at:now,content:'Local synthetic qualification only',tags:[['t',action],['x',hash],['server',server],['expiration',String(now+expires)]]},secret)));
|
||||
}
|
||||
async function check(label: string, expected: number, path: string, init={}) {
|
||||
const r=await fetch(base+path,init);
|
||||
if(r.status!==expected) throw new Error(`${label}: expected ${expected}, got ${r.status}: ${await r.text()}`);
|
||||
console.log(`PASS ${label}: ${r.status}`);return r;
|
||||
}
|
||||
const upload=(token?:string)=>({method:'PUT',headers:{'content-type':'text/html',...(token?{authorization:token}:{})},body});
|
||||
await check('unauthenticated upload denied',401,'/upload',upload());
|
||||
await check('unlisted identity denied',401,'/upload',upload(auth('upload',other)));
|
||||
await check('wrong host denied',401,'/upload',upload(auth('upload',key,'wrong.invalid')));
|
||||
await check('expired token denied',401,'/upload',upload(auth('upload',key,'127.0.0.1',-300)));
|
||||
const stored=await (await check('signed profile upload',201,'/upload',upload(auth('upload')))).json();
|
||||
if(stored.sha256!==hash || stored.size!==bytes.length) throw new Error('Wrong descriptor');
|
||||
const read=await check('read stored bytes',200,'/'+hash);
|
||||
if(await read.text()!==body) throw new Error('Stored bytes differ');
|
||||
if(!read.headers.get('content-security-policy')?.includes('sandbox') || read.headers.get('content-disposition')!=='attachment') throw new Error('Active content not sandboxed');
|
||||
console.log('PASS exact bytes and sandboxed attachment');
|
||||
await check('anonymous list denied',401,'/list/'+getPublicKey(key));
|
||||
await check('other identity cannot list owner',403,'/list/'+getPublicKey(key),{headers:{authorization:auth('list',other)}});
|
||||
await check('owner list',200,'/list/'+getPublicKey(key),{headers:{authorization:auth('list')}});
|
||||
await check('mirror disabled',403,'/mirror',{method:'PUT',headers:{authorization:auth('upload')}});
|
||||
await check('canonical signer provider',200,'/nostr-provider.js');
|
||||
await check('health',200,'/healthz');
|
||||
console.log('PRESERVE_HASH '+hash);
|
||||
@@ -0,0 +1,40 @@
|
||||
// Run against the disposable packaged UI forwarded to 127.0.0.1:48191.
|
||||
// The signer and upload transport are mocked; no real keys or public endpoints.
|
||||
const { chromium } = require('../../../neode-ui/node_modules/@playwright/test');
|
||||
const { createHash } = require('node:crypto');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({headless:true});
|
||||
const page = await browser.newPage({viewport:{width:390,height:844}});
|
||||
page.on('console',m=>console.log('browser:',m.text())); page.on('pageerror',e=>console.log('page error:',e.message));
|
||||
const outgoing=[]; let uploads=0;
|
||||
await page.route('**/*', async route => {
|
||||
const u=new URL(route.request().url());
|
||||
if(u.origin!=='http://127.0.0.1:48191'){outgoing.push(u.origin);return route.abort();}
|
||||
if(u.pathname==='/nostr-provider.js')return route.fulfill({contentType:'application/javascript',body:`window.signCalls=[];window.chooseCalls=0;window.deny=true;window.archipelagoNostr={selectIdentity:async()=>{window.chooseCalls++}};window.nostr={getPublicKey:async()=>'${'a'.repeat(64)}',signEvent:async e=>{window.signCalls.push(e);if(window.deny)throw new Error('User declined signing');return {...e,pubkey:'${'a'.repeat(64)}',id:'${'b'.repeat(64)}',sig:'${'c'.repeat(128)}'}}};`});
|
||||
if(u.pathname==='/upload'){
|
||||
uploads++; const body=route.request().postDataBuffer();
|
||||
const token=JSON.parse(Buffer.from(route.request().headers().authorization.slice(6),'base64').toString());
|
||||
const hash=createHash('sha256').update(body).digest('hex');
|
||||
if(!token.tags.some(t=>t[0]==='x'&&t[1]===hash)||!token.tags.some(t=>t[0]==='server'&&t[1]==='127.0.0.1'))throw Error('Auth scope mismatch');
|
||||
return route.fulfill({contentType:'application/json',body:JSON.stringify({sha256:hash,size:body.length})});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto('http://127.0.0.1:48191/');
|
||||
await page.waitForFunction(()=>typeof window.nostr==='object');
|
||||
if(!await page.locator('#upload').isDisabled())throw Error('Upload enabled before consent');
|
||||
await page.waitForFunction(()=>window.chooseCalls===1);
|
||||
await page.waitForFunction(()=>document.querySelector('#pubkey').textContent==='a'.repeat(64));
|
||||
await page.locator('#file').setInputFiles({name:'local-fixture.txt',mimeType:'text/plain',buffer:Buffer.from('Synthetic local file')});
|
||||
await page.locator('#approve').check(); await page.locator('#upload').click();
|
||||
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('User declined'));
|
||||
if(uploads!==0)throw Error('Uploaded despite signing refusal');
|
||||
await page.evaluate(()=>window.deny=false);
|
||||
await page.locator('#upload').click();
|
||||
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Stored on this node'));
|
||||
if(uploads!==1 || outgoing.length)throw Error('Unexpected upload or external request');
|
||||
if(await page.locator('#approve').isChecked())throw Error('Approval was retained after upload');
|
||||
if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('Mobile horizontal overflow');
|
||||
console.log('PASS packaged local UI: automatic identity chooser, explicit consent, signer denial, scoped upload, consent reset, mobile width, no external requests (mock signer/transport; real signer still pending)');
|
||||
await browser.close();
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,32 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[2] / 'scripts/public-web-gateway/enroll.py'
|
||||
class EnrollmentTests(unittest.TestCase):
|
||||
def test_private_export_duplicate_domain_and_explicit_rotation(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(root/'key'), '-out', str(root/'ca'), '-days', '1', '-subj', '/CN=gateway.example'], check=True, capture_output=True)
|
||||
config = {'bindPort': 7400, 'auth': {'method': 'token', 'token': 't'*64}, 'transport': {'tls': {'force': True}}, 'httpPlugins': [{'ops': ['Login', 'NewProxy', 'Ping', 'NewWorkConn', 'NewUserConn']}]}
|
||||
path = root/'frps.json'; path.write_text(json.dumps(config)); path.chmod(0o600)
|
||||
base = [sys.executable, str(SCRIPT), '--frps-config', str(path), '--policy', str(root/'policy.json'), '--ca', str(root/'ca'), '--host', 'gateway.example', '--tls-server-name', 'gateway.example', '--domain', 'site.example']
|
||||
result = subprocess.run(base + ['--name', 'node-a', '--output', str(root/'node-a.json')], capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
private = json.loads((root/'node-a.json').read_text())
|
||||
self.assertNotIn(private['enrollment_token'].encode(), result.stdout + result.stderr)
|
||||
self.assertEqual((root/'node-a.json').stat().st_mode & 0o777, 0o600)
|
||||
first = (root/'policy.json').read_bytes()
|
||||
result = subprocess.run(base + ['--name', 'node-b', '--output', str(root/'node-b.json')], capture_output=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(first, (root/'policy.json').read_bytes())
|
||||
self.assertFalse((root/'node-b.json').exists())
|
||||
result = subprocess.run(base + ['--name', 'node-a', '--rotate', '--output', str(root/'rotated.json')], capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
self.assertNotEqual(private['enrollment_token'], json.loads((root/'rotated.json').read_text())['enrollment_token'])
|
||||
self.assertNotEqual(first, (root/'policy.json').read_bytes())
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location('gateway_policy', Path(__file__).resolve().parents[2] / 'scripts/public-web-gateway/policy.py')
|
||||
policy = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(policy)
|
||||
|
||||
|
||||
class PolicyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.token = 'synthetic-test-token-' * 3
|
||||
self.user = {'user': 'framework', 'metas': {'enrollment_token': self.token}}
|
||||
self.entries = {'framework': {'enabled': True, 'token_sha256': hashlib.sha256(self.token.encode()).hexdigest(), 'domains': ['free.archipelago.builders']}}
|
||||
self.proxy = {'user': self.user, 'proxy_name': 'framework.website', 'proxy_type': 'https', 'custom_domains': ['free.archipelago.builders']}
|
||||
|
||||
def test_allow_assigned_https_only(self):
|
||||
self.assertTrue(policy.authorize('Login', self.user, self.entries))
|
||||
for op in ('NewProxy', 'NewUserConn', 'Ping', 'NewWorkConn'):
|
||||
self.assertTrue(policy.authorize(op, self.proxy, self.entries))
|
||||
|
||||
def test_revoke_and_rotate_apply_to_all_operations(self):
|
||||
for op in policy.OPS:
|
||||
content = self.user if op == 'Login' else self.proxy
|
||||
self.entries['framework']['enabled'] = False
|
||||
self.assertFalse(policy.authorize(op, content, self.entries))
|
||||
self.entries['framework']['enabled'] = True
|
||||
self.entries['framework']['token_sha256'] = '0' * 64
|
||||
self.assertFalse(policy.authorize(op, content, self.entries))
|
||||
|
||||
def test_no_other_domains_protocols_or_shared_groups(self):
|
||||
for key, value in [('custom_domains', ['other.example']), ('custom_domains', ['free.archipelago.builders', 'other.example']), ('proxy_type', 'tcp'), ('proxy_type', 'http'), ('remote_port', 22), ('subdomain', 'admin'), ('group', 'shared'), ('locations', ['/']), ('proxy_name', 'another.website')]:
|
||||
with self.subTest(key=key, value=value):
|
||||
self.assertFalse(policy.authorize('NewProxy', {**self.proxy, key: value}, self.entries))
|
||||
|
||||
def test_missing_or_malformed_auth_is_denied(self):
|
||||
for user in ({}, {'user': 'framework'}, {'user': 'framework', 'metas': []}, {'user': 'framework', 'metas': {'enrollment_token': 'wrong'}}):
|
||||
self.assertFalse(policy.authorize('Login', user, self.entries))
|
||||
self.assertFalse(policy.authorize('Unknown', self.proxy, self.entries))
|
||||
self.assertFalse(policy.authorize('Login', self.user, {}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,53 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location('node_router', Path(__file__).resolve().parents[2] / 'docker/public-web-router/router.py')
|
||||
router = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(router)
|
||||
|
||||
class RouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config = {'schema': 1, 'gateway': {'host': '192.0.2.1', 'port': 7400, 'node_id': 'node-a', 'transport_token': 'a'*64, 'enrollment_token': 'b'*64, 'ca_pem': '-----BEGIN CERTIFICATE-----\nexample\n-----END CERTIFICATE-----', 'tls_server_name': 'gateway.example', 'domains': ['site.example']}, 'routes': [{'id': 'site-a', 'domain': 'site.example', 'fips_address': 'fd00::1', 'port': 32000}]}
|
||||
|
||||
def test_tls_ends_on_node_with_pinned_control_channel(self):
|
||||
caddy, frpc, pem = router.render(self.config)
|
||||
self.assertIn('disable_http_challenge', caddy)
|
||||
self.assertNotIn('tls internal', caddy)
|
||||
self.assertIn('bind 127.0.0.1', caddy)
|
||||
self.assertEqual(frpc['proxies'][0]['type'], 'https')
|
||||
self.assertEqual(frpc['transport']['tls']['serverName'], 'gateway.example')
|
||||
self.assertIn('trustedCaFile', frpc['transport']['tls'])
|
||||
|
||||
def test_refuses_management_and_arbitrary_upstreams(self):
|
||||
for port in (22, 443, 7474, 8191, 31999, 32032, True):
|
||||
self.config['routes'][0]['port'] = port
|
||||
with self.assertRaises(ValueError): router.render(self.config)
|
||||
self.config['routes'][0]['port'] = 32000
|
||||
for address in ('127.0.0.1', '::1', '2001:db8::1'):
|
||||
self.config['routes'][0]['fips_address'] = address
|
||||
with self.assertRaises(ValueError): router.render(self.config)
|
||||
|
||||
def test_refuses_config_injection_and_duplicate_domains(self):
|
||||
for key in ('domain', 'id'):
|
||||
old = self.config['routes'][0][key]
|
||||
self.config['routes'][0][key] = 'site.example\n import /secret'
|
||||
with self.assertRaises(ValueError): router.render(self.config)
|
||||
self.config['routes'][0][key] = old
|
||||
self.config['routes'].append(dict(self.config['routes'][0]))
|
||||
with self.assertRaises(ValueError): router.render(self.config)
|
||||
|
||||
def test_app_routes_keep_the_expected_app_gate_identity(self):
|
||||
self.config['routes'][0].update(id='app-photoprism', app_id='photoprism', port=2342)
|
||||
caddy, _, _ = router.render(self.config)
|
||||
self.assertIn('header_up X-Archipelago-App photoprism', caddy)
|
||||
self.config['routes'][0]['id'] = 'app-another'
|
||||
with self.assertRaises(ValueError): router.render(self.config)
|
||||
|
||||
def test_test_certificates_require_explicit_mode(self):
|
||||
self.config['certificate_mode'] = 'test'
|
||||
self.assertIn('tls internal', router.render(self.config)[0])
|
||||
self.config['certificate_mode'] = 'insecure'
|
||||
with self.assertRaises(ValueError): router.render(self.config)
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
Reference in New Issue
Block a user