#!/usr/bin/env node /* * Copyright (C) 2024-present Puter Technologies Inc. * * This file is part of Puter. * * Puter is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /** * Probe which routes a credential reaches, so the same sweep can be run against * a deployment before and after a gate change. * * Usage: * node tools/checkFullAccessRoutes.mjs --token \ * [--api https://api.puter.com] [--origin https://puter.com] [--json] * * Reads PUTER_API_ORIGIN / PUTER_ORIGIN / PUTER_TOKEN when the flags are * omitted. Every probe is a read or an idempotent write; nothing is deleted. */ import { io as ioClient } from 'socket.io-client'; const argv = process.argv.slice(2); const flag = (name, fallback) => { const at = argv.indexOf(`--${name}`); return at === -1 ? fallback : argv[at + 1]; }; const token = flag('token', process.env.PUTER_TOKEN); const apiOrigin = ( flag('api', process.env.PUTER_API_ORIGIN) ?? 'https://api.puter.com' ).replace(/\/$/, ''); const origin = ( flag('origin', process.env.PUTER_ORIGIN) ?? apiOrigin.replace('://api.', '://') ).replace(/\/$/, ''); const asJson = argv.includes('--json'); if (!token) { console.error( 'Missing token. Pass --token or set PUTER_TOKEN.\n' + 'Mint one at Settings -> Account -> API Token, or read a session\n' + "token from localStorage['auth_token_v2'].", ); process.exit(2); } /** * `expect` is what the credential under test should get once the change ships. * `was` records the behavior before it, so a run against an un-upgraded * deployment is still readable rather than a wall of failures. */ const PROBES = [ // -- Routes a privileged app needs ------------------------------- { group: 'app surface', method: 'GET', path: '/get-dev-profile', expect: 200, was: 403 }, { group: 'app surface', method: 'GET', path: '/auth/list-permissions', expect: 200, was: 403 }, { group: 'app surface', method: 'GET', path: '/share/shared-by-me', expect: 200, was: 200 }, { group: 'app surface', method: 'GET', path: '/share/shared-by-me/apps', expect: 200, was: 403 }, { group: 'app surface', method: 'GET', path: '/share/audit', expect: 200, was: 403 }, { group: 'app surface', method: 'GET', path: '/profile', expect: 200, was: 200 }, { group: 'app surface', method: 'POST', path: '/profile', // Writes the bio back as-is, read from GET /profile first. body: 'echo-bio', expect: 200, was: 403, }, { group: 'app surface', method: 'POST', path: '/rao', body: { app_uid: flag('app-uid', 'app-0b37f054-07d4-4627-8765-11bd23e889d4') }, expect: 200, was: 403, }, // -- Routes the SDK uses on every launch ------------------------- { group: 'sdk baseline', method: 'GET', path: '/whoami', expect: 200, was: 200 }, { group: 'sdk baseline', method: 'POST', path: '/stat', body: { path: '~/' }, expect: 200, was: 200 }, { group: 'sdk baseline', method: 'GET', path: '/apps', expect: 200, was: 200 }, { group: 'sdk baseline', method: 'GET', path: '/cache/last-change-timestamp', expect: 200, was: 200 }, // -- Account and security management: stays shut ----------------- { group: 'stays shut', method: 'GET', path: '/share/blocks', expect: 403, was: 403 }, { group: 'stays shut', method: 'GET', path: '/app-feedback/target?app=dev-center', expect: 403, was: 403 }, { group: 'stays shut', method: 'GET', path: '/auth/list-sessions', expect: 403, was: 403 }, { group: 'stays shut', method: 'POST', path: '/auth/get-user-app-token', body: { origin: 'https://probe.invalid' }, expect: 403, was: 403, }, { group: 'stays shut', method: 'POST', path: '/auth/grant-user-app', body: { app_uid: 'app-probe', permission: 'probe' }, expect: 403, was: 403, }, ]; const call = async (probe, bio) => { let body = probe.body; if (body === 'echo-bio') body = { bio }; const res = await fetch(new URL(probe.path, apiOrigin), { method: probe.method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}`, }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); return res.status; }; /** Resolves the handshake verdict without holding the connection open. */ const probeSocket = () => new Promise((resolve) => { const socket = ioClient(origin, { auth: { auth_token: token }, transports: ['websocket'], reconnection: false, timeout: 15_000, }); const settle = (verdict) => { socket.disconnect(); resolve(verdict); }; socket.on('connect', () => settle({ ok: true, detail: 'connected' })); socket.on('connect_error', (err) => settle({ ok: false, detail: err?.message ?? 'connect_error' }), ); setTimeout(() => settle({ ok: false, detail: 'timed out' }), 20_000); }); const run = async () => { const results = []; // The profile write echoes the current bio back, so a sweep leaves the // account exactly as it found it. let bio = ''; try { const res = await fetch(new URL('/profile', apiOrigin), { headers: { authorization: `Bearer ${token}` }, }); if (res.ok) bio = (await res.json())?.bio ?? ''; } catch { // Leave it empty; the probe reports whatever status it gets. } for (const probe of PROBES) { let status; try { status = await call(probe, bio); } catch (err) { status = `ERR ${err?.message ?? err}`; } results.push({ ...probe, status, pass: status === probe.expect, unchanged: status === probe.was && probe.was !== probe.expect, }); } const socket = await probeSocket(); results.push({ group: 'app surface', method: 'WS', path: '/socket.io', expect: 200, was: 403, status: socket.ok ? 200 : 403, detail: socket.detail, pass: socket.ok, unchanged: !socket.ok, }); if (asJson) { console.log(JSON.stringify({ apiOrigin, origin, results }, null, 2)); } else { console.log(`api=${apiOrigin} gui=${origin}\n`); let group = ''; for (const r of results) { if (r.group !== group) { group = r.group; console.log(`-- ${group} --`); } const mark = r.pass ? 'PASS' : r.unchanged ? 'OLD ' : 'FAIL'; const want = r.pass ? '' : ` (want ${r.expect})`; const note = r.detail ? ` ${r.detail}` : ''; console.log( ` ${mark} ${String(r.method).padEnd(5)} ${r.path.padEnd(42)} ${r.status}${want}${note}`, ); } const failed = results.filter((r) => !r.pass); const old = failed.filter((r) => r.unchanged); console.log( `\n${results.length - failed.length}/${results.length} as expected` + (old.length ? `; ${old.length} still on the old behavior` : '') + (failed.length - old.length ? `; ${failed.length - old.length} unexplained` : ''), ); } process.exit(results.some((r) => !r.pass && !r.unchanged) ? 1 : 0); }; run().catch((err) => { console.error(err); process.exit(1); });