pithos-hosted: serve the browser app on its own hostname
With app: { dir, hosts } set, the gateway serves web/ at / and /_app/ for
those hostnames only. Sharing an origin with the API means the page needs
no CORS and keeps its keys away from every other site; the CSP keeps it
from loading or calling anything else. Signed S3 requests are routed
first, so a drive called _app is unaffected.
This commit is contained in:
parent
2eedcb8ff6
commit
e5dfb67678
2 changed files with 80 additions and 0 deletions
|
|
@ -434,6 +434,37 @@ export async function createHosted(cfg, log = console.log) {
|
|||
return serveObject(req, res, l.id, rule.bucket, rule.prefix, { inline: !url.searchParams.has('download') });
|
||||
}
|
||||
|
||||
// ---- the browser app ---------------------------------------------------------------
|
||||
// cfg.app = { dir, hosts: ['pithos.silentmode.st'] }. Its own origin keeps the
|
||||
// keys a signed-in page holds away from every other site, and the strict CSP
|
||||
// keeps the page from loading or talking to anything but this origin.
|
||||
const APP_TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', '.json': 'application/json', '.webmanifest': 'application/manifest+json' };
|
||||
const APP_CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; connect-src 'self'; worker-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; object-src 'none'";
|
||||
function appHost(req) {
|
||||
if (!cfg.app?.dir) return false;
|
||||
const h = String(req.headers.host || '').toLowerCase().replace(/:\d+$/, '');
|
||||
return (cfg.app.hosts || []).includes(h);
|
||||
}
|
||||
function serveApp(req, res, p) {
|
||||
const root = path.resolve(cfg.app.dir);
|
||||
const rel = p === '/' ? 'index.html' : decodeURIComponent(p.slice('/_app/'.length));
|
||||
const file = path.resolve(root, rel);
|
||||
const type = APP_TYPES[path.extname(file).toLowerCase()];
|
||||
if (!file.startsWith(root + path.sep) || !type || rel.split(/[\\/]/).some((s) => s.startsWith('.')) || !fs.existsSync(file) || !fs.statSync(file).isFile()) throw httpError(404, 'not found');
|
||||
const body = fs.readFileSync(file);
|
||||
res.writeHead(200, {
|
||||
'content-type': type,
|
||||
'content-length': body.length,
|
||||
'cache-control': 'no-cache',
|
||||
'content-security-policy': APP_CSP,
|
||||
'x-content-type-options': 'nosniff',
|
||||
'referrer-policy': 'no-referrer',
|
||||
'cross-origin-opener-policy': 'same-origin',
|
||||
'permissions-policy': 'camera=(), microphone=(), geolocation=()',
|
||||
});
|
||||
res.end(req.method === 'HEAD' ? undefined : body);
|
||||
}
|
||||
|
||||
// ---- routing -------------------------------------------------------------------
|
||||
async function handle(req, res) {
|
||||
const url = new URL(req.url, cfg.publicUrl);
|
||||
|
|
@ -441,6 +472,10 @@ export async function createHosted(cfg, log = console.log) {
|
|||
if (isS3) return s3(req, res, url);
|
||||
const p = url.pathname;
|
||||
|
||||
// The browser app (web/) on its own hostname: "/" and "/_app/*". Signed S3
|
||||
// requests were routed above, so a drive named "_app" is unaffected.
|
||||
if (appHost(req) && (req.method === 'GET' || req.method === 'HEAD') && (p === '/' || p.startsWith('/_app/'))) return serveApp(req, res, p);
|
||||
|
||||
if (p.startsWith('/p/') && (req.method === 'GET' || req.method === 'HEAD')) return servePublic(req, res, url);
|
||||
if (p.startsWith('/l/') && (req.method === 'GET' || req.method === 'HEAD')) return serveLink(req, res, url);
|
||||
|
||||
|
|
|
|||
45
hosted/test/app-route.test.js
Normal file
45
hosted/test/app-route.test.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// The browser app is served only on its own hostname, only from web/, with a
|
||||
// CSP that keeps the page on its origin. Signed S3 requests are never caught.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHosted } from '../server.js';
|
||||
|
||||
const web = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'web');
|
||||
const get = (port, p, headers = {}) => new Promise((resolve, reject) => {
|
||||
http.get({ host: '127.0.0.1', port, path: p, headers }, (res) => { let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: b })); }).on('error', reject);
|
||||
});
|
||||
|
||||
test('app files: own host only, no escapes, strict CSP', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pithos-app-route-'));
|
||||
const port = 18700 + Math.floor(Math.random() * 200);
|
||||
const h = await createHosted({
|
||||
listen: `127.0.0.1:${port}`, publicUrl: `http://127.0.0.1:${port}`, root, s3dBinary: null, portBase: 23000, maxRunning: 1, idleMinutes: 1,
|
||||
gate: { invites: [] }, app: { dir: web, hosts: ['pithos.example'] },
|
||||
}, () => {});
|
||||
t.after(async () => { await h.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
const index = await get(port, '/', { host: 'pithos.example' });
|
||||
assert.equal(index.status, 200);
|
||||
assert.match(index.headers['content-type'], /text\/html/);
|
||||
assert.match(index.headers['content-security-policy'], /default-src 'self'; script-src 'self'/);
|
||||
assert.match(index.headers['content-security-policy'], /frame-ancestors 'none'/);
|
||||
assert.equal(index.headers['x-content-type-options'], 'nosniff');
|
||||
assert.match(index.body, /\/_app\/app\.js/);
|
||||
|
||||
assert.equal((await get(port, '/_app/lib/crypt.js', { host: 'pithos.example' })).status, 200);
|
||||
// the S3 host does not serve the app
|
||||
assert.notEqual((await get(port, '/', { host: 's3.example' })).status, 200);
|
||||
// no way out of web/, no dotfiles, no unknown types
|
||||
for (const p of ['/_app/../package.json', '/_app/%2e%2e/package.json', '/_app/..%2fpackage.json', '/_app/.hidden.js', '/_app/lib', '/_app/nope.exe']) {
|
||||
assert.equal((await get(port, p, { host: 'pithos.example' })).status, 404, p);
|
||||
}
|
||||
// a signed S3 request for "/" is S3, not the app
|
||||
const s3 = await get(port, '/', { host: 'pithos.example', authorization: 'AWS4-HMAC-SHA256 Credential=AKIDUNKNOWN/20261005/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=00' });
|
||||
assert.notEqual(s3.status, 200);
|
||||
assert.doesNotMatch(s3.body, /<html/);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue