openvibers/openvibe.connect Commits

"Anyone signed in" is staff-first, like Space (#1)

OP OpenVibers committed 11 days ago · f2232f28d5 · 8 files, +41 −16

Anyone can make an OpenVibe account, so a share every signed-in person may open is nearly public. Space opens public
publishing to staff until the abuse check and a DMCA agent are in place; Connect does the same for its signin mode
(Network role admin), with the choice shown disabled and its reason for everyone else. Owner and named shares stay
open to all.

Changes

ddiff --git a/README.md b/README.md
iindex 13efa6a..09aa910 100644
---- a/README.md
++++ b/README.md
@@@ -37,7 +37,7 @@ only ever connects to `localhost` on the port you gave it.
|---|---|
| `owner` (default) | only you |
| `named` | you and the OpenVibe usernames you list |
--| `signin` | anyone signed in to OpenVibe |
++| `signin` | anyone signed in to OpenVibe (OpenVibe staff only until the abuse check is in, as with Space's public publishing) |
Public shares (no sign-in) come once the abuse check and openvibe.website's entry in the Public Suffix List are in
(ADR-057, CON-9 and CON-21). Changing who can open a share signs everyone out of it.
ddiff --git a/SECURITY.md b/SECURITY.md
iindex 7a856d9..fff9837 100644
---- a/SECURITY.md
++++ b/SECURITY.md
@@@ -30,7 +30,8 @@ github.com/OpenVibers. Machine-readable contact details are at `/.well-known/sec
- **The gate.** Every visitor signs in with OpenVibe and must be allowed by the share's policy (only the owner, the
owner and named people, or anyone signed in). There are no public shares: those wait for the abuse check and the
-- Public Suffix List entry for openvibe.website (ADR-057, CON-9 and CON-21).
++ Public Suffix List entry for openvibe.website (ADR-057, CON-9 and CON-21). Because anyone can make an OpenVibe
++ account, "anyone signed in" is open to OpenVibe staff only until then.
- **Host-only cookies.** The gate's cookie on a share host is `__Host-ovc` (Secure, HttpOnly, SameSite=Lax, Path=/, no
Domain), HMAC-signed and bound to that share and its policy version; changing the policy invalidates every cookie.
A Set-Cookie from the shared app loses any Domain attribute, and the app can never set or read the gate's cookie.
ddiff --git a/public/css/connect.css b/public/css/connect.css
iindex 9620776..67a0ee2 100644
---- a/public/css/connect.css
++++ b/public/css/connect.css
@@@ -82,7 +82,10 @@
.cn-modes legend { font-size: 14px; font-weight: 600; margin-bottom: 8px; padding: 0; }
.cn-modes label { display: flex; align-items: center; gap: 10px; min-height: var(--ovk-tap, 44px); padding: 0 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-bg); cursor: pointer; font-size: 15px; }
.cn-modes label:has(input:checked) { border-color: color-mix(in srgb, var(--room-accent) 60%, var(--ovk-line)); }
--.cn-modes input { accent-color: var(--room-accent); width: 18px; height: 18px; margin: 0; }
++.cn-modes input { accent-color: var(--room-accent); width: 18px; height: 18px; margin: 0; flex: none; }
++.cn-modes label > span { display: grid; gap: 1px; padding: 6px 0; }
++.cn-modes label > span small { font-size: 12px; color: var(--ovk-muted); }
++.cn-modes label.is-off { cursor: not-allowed; color: var(--ovk-muted); }
@media (min-width: 720px) { .cn-modes { grid-template-columns: repeat(3, minmax(0, 1fr)); } .cn-modes legend { grid-column: 1 / -1; } }
.cn-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: var(--ovk-tap, 44px); padding: 0 18px; border-radius: 12px; border: 1px solid transparent; background: var(--room-fill); color: var(--room-on); font: inherit; font-weight: 700; font-size: 15px; cursor: pointer; text-decoration: none; justify-self: start; }
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 826e876..a0e2998 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -28,7 +28,7 @@ function createApi(ctx) {
r.use(principal.requirePerson);
const fail = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
-- const owner = (req) => ({ subject: req.principal.subject, username: req.principal.username });
++ const owner = (req) => ({ subject: req.principal.subject, username: req.principal.username, role: req.principal.role });
const shape = (s) => ({ ...s, connected: tunnels.isConnected(s.id) });
const guard = (fn) => async (req, res) => {
try { return await fn(req, res); } catch (err) {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 11dc0ab..d8045b3 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -26,7 +26,7 @@ function createPageRoutes(ctx) {
r.use(express.urlencoded({ extended: false, limit: '16kb' }));
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
-- const owner = (req) => ({ subject: req.viewer.subject, username: req.viewer.username });
++ const owner = (req) => ({ subject: req.viewer.subject, username: req.viewer.username, role: req.viewer.role });
const loginTo = (path) => `/auth/login?next=${encodeURIComponent(path)}`;
const guard = (req, res) => {
if (!signedIn(req)) { res.redirect(303, loginTo(req.originalUrl)); return false; }
@@@ -59,7 +59,7 @@ function createPageRoutes(ctx) {
const input = { label: req.body.label, port: req.body.port, mode: req.body.mode, named: req.body.named };
try {
const { share, key } = await shares.create(owner(req), input);
-- return page(req, res, { title: share.host, body: views.sharePage({ share, connected: false, key, baseUrl: config.baseUrl, notice: 'Your share is made. Run the command below on your computer.' }) }, 201);
++ return page(req, res, { title: share.host, body: views.sharePage({ share, connected: false, key, baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', notice: 'Your share is made. Run the command below on your computer.' }) }, 201);
} catch (err) {
if (!(err instanceof ShareError)) throw err;
const list = await shares.list(req.viewer.subject);
@@@ -81,7 +81,7 @@ function createPageRoutes(ctx) {
async function showShare(req, res, share, extra = {}, status = 200) {
const tunnel = tunnels.get(share.id);
const visits = await shares.visits(share.id);
-- return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, baseUrl: config.baseUrl, ...extra }) }, status);
++ return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', ...extra }) }, status);
}
r.get('/shares/:id', async (req, res) => {
ddiff --git a/server/shares.js b/server/shares.js
iindex 658a695..5c03575 100644
---- a/server/shares.js
++++ b/server/shares.js
@@@ -16,7 +16,9 @@
*
* Modes (website.policy@1's subset that v0 serves): owner (only the person who made it), named (the owner and the
* OpenVibe usernames listed) and signin (anyone signed in to OpenVibe). A public share waits for the abuse screen
-- * and the Public Suffix List entry (ADR-057, CON-9 and CON-21), so it is refused here.
++ * and the Public Suffix List entry (ADR-057, CON-9 and CON-21), so it is refused here. Since anyone can make an
++ * OpenVibe account, "anyone signed in" is nearly public too: like Space's public publishing, it is open to OpenVibe
++ * staff (Network role admin) until the abuse check is in.
*/
const crypto = require('crypto');
@@@ -24,6 +26,9 @@ const LABEL_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,39}$/;
const MODES = ['owner', 'named', 'signin'];
const RESERVED_LABELS = new Set(['www', 'api', 'app', 'admin', 'connect', 'hooks', 'edge', 'share', 'status', 'mail', 'space', 'auth', 'login', 'openvibe']);
const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
++const SIGNIN_NOT_YET = '"Anyone signed in" opens with the abuse check. For now, name the people who may open it.';
++
++const isStaff = (owner) => Boolean(owner && owner.role === 'admin');
class ShareError extends Error {
constructor(status, code, message) { super(message); this.status = status; this.code = code; }
@@@ -136,6 +141,7 @@ function createShares(s, { config }) {
const mode = input.mode == null || input.mode === '' ? 'owner' : String(input.mode);
if (mode === 'public') throw new ShareError(422, 'share.public_not_yet', 'Public shares open once the abuse check is in. For now a share asks every visitor to sign in with OpenVibe.');
if (!MODES.includes(mode)) throw new ShareError(422, 'share.invalid_mode', `Who can open it: ${MODES.join(', ')}.`);
++ if (mode === 'signin' && !isStaff(owner)) throw new ShareError(403, 'share.signin_not_yet', SIGNIN_NOT_YET);
const named = normalizeNamed(input.named);
const host = hostFor(label, owner.username, domain);
if (!host) throw new ShareError(422, 'share.name_too_long', 'That name and your username make an address longer than 63 characters. Pick a shorter name.');
@@@ -171,6 +177,7 @@ function createShares(s, { config }) {
const mode = input.mode == null ? share.mode : String(input.mode);
if (mode === 'public') throw new ShareError(422, 'share.public_not_yet', 'Public shares open once the abuse check is in.');
if (!MODES.includes(mode)) throw new ShareError(422, 'share.invalid_mode', `Who can open it: ${MODES.join(', ')}.`);
++ if (mode === 'signin' && mode !== share.mode && !isStaff(owner)) throw new ShareError(403, 'share.signin_not_yet', SIGNIN_NOT_YET);
const named = input.named === undefined ? share.named : normalizeNamed(input.named);
const port = input.port === undefined ? share.local_port : Number(input.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ShareError(422, 'share.invalid_port', 'The port is a number from 1 to 65535.');
@@@ -247,4 +254,4 @@ function iso(v) {
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
--module.exports = { createShares, ShareError, hostFor, parseHost, handleOf, normalizeNamed, LABEL_RE, MODES, RESERVED_LABELS, sha256 };
++module.exports = { createShares, ShareError, isStaff, hostFor, parseHost, handleOf, normalizeNamed, LABEL_RE, MODES, RESERVED_LABELS, sha256 };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 83304db..0c28a7b 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -64,7 +64,15 @@ function shareRow(s, { connected }) {
${kit.icon('chevron')}</a></li>`;
}
--function createForm({ values = {}, error = null, username = '' } = {}) {
++/** The three choices; "anyone signed in" is staff-only until the abuse check (shares.js). */
++function modeChoices(current, staff) {
++ return Object.entries(MODE_TEXT).map(([k, v]) => {
++ const off = k === 'signin' && !staff && current !== 'signin';
++ return `<label${off ? ' class="is-off"' : ''}><input type="radio" name="mode" value="${k}"${current === k ? ' checked' : ''}${off ? ' disabled' : ''}> <span>${esc(v)}${off ? '<small>opens with the abuse check</small>' : ''}</span></label>`;
++ }).join('');
++}
++
++function createForm({ values = {}, error = null, username = '', staff = false } = {}) {
const handle = String(username || 'you').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'you';
return `<form class="cn-form" method="post" action="/shares" id="new">
<h2>Make a share</h2>
@@@ -74,7 +82,7 @@ ${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}
<label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" required inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(values.port || '')}" placeholder="8080"></label>
</div>
<fieldset class="cn-modes"><legend>Who can open it</legend>
--${Object.entries(MODE_TEXT).map(([k, v]) => `<label><input type="radio" name="mode" value="${k}"${(values.mode || 'owner') === k ? ' checked' : ''}> ${esc(v)}</label>`).join('')}
++${modeChoices(values.mode || 'owner', staff)}
</fieldset>
<label class="cn-field"><span>People (for "the people you name")</span><input name="named" maxlength="2000" value="${esc(values.named || '')}" placeholder="sam, riley" autocapitalize="none" autocomplete="off" spellcheck="false"><small>OpenVibe usernames, separated by commas</small></label>
<button class="cn-btn" type="submit">${kit.icon('plus')} Make the share</button>
@@@ -90,7 +98,7 @@ function yourShares({ viewer, shares, connectedIds, form }) {
? `<ul class="cn-shares">${open.map((s) => shareRow(s, { connected: connectedIds.has(s.id) })).join('')}</ul>`
: '<p class="cn-empty">No shares yet. Make one below: a name, a port, who can open it.</p>';
const closedList = closed.length ? `<details class="cn-closed"><summary>Closed shares (${closed.length})</summary><ul class="cn-shares">${closed.map((s) => shareRow(s, { connected: false })).join('')}</ul></details>` : '';
-- return `<section class="cn-yours" aria-labelledby="cn-yours-h"><h2 id="cn-yours-h">Your shares</h2>${list}${closedList}${createForm(form || { username: viewer.username })}</section>`;
++ return `<section class="cn-yours" aria-labelledby="cn-yours-h"><h2 id="cn-yours-h">Your shares</h2>${list}${closedList}${createForm({ username: viewer.username, ...(form || {}), staff: viewer.role === 'admin' })}</section>`;
}
function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseUrl, form = null }) {
@@@ -126,7 +134,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU
},
{
title: 'You pick who gets in',
-- text: 'Only you, you and the people you name, or anyone signed in to OpenVibe. Change it any time and everyone already in is asked again. Your app is told who each visitor is, so it can greet them by name.',
++ text: 'Only you, or you and the people you name. Change it any time and everyone already in is asked again. Your app is told who each visitor is, so it can greet them by name.',
html: picker(),
},
],
@@@ -143,7 +151,7 @@ function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseU
faq: {
items: [
['What can the server make my computer do?', 'Nothing but answer requests for your share on the one port you gave the client, on localhost. The client never opens a connection anywhere else, and you can read all of it: it is one file.'],
-- ['Who can open my share?', 'People you allow, after they sign in with OpenVibe: only you, you and named people, or anyone signed in. Changing who can open it signs everyone out of that share at once.'],
++ ['Who can open my share?', 'People you allow, after they sign in with OpenVibe: only you, or you and the people you name. "Anyone signed in" is open to OpenVibe staff until the abuse check is in. Changing who can open a share signs everyone out of it at once.'],
['Can I make it public?', 'Not yet. A public address can be abused for phishing, so public shares come with the abuse check and openvibe.website\'s entry in the Public Suffix List. Until then every visitor signs in.'],
['What does my app see?', 'The request as the browser sent it, with Host set to localhost:<port>, the share\'s address in X-Forwarded-Host, the visitor\'s address in X-Forwarded-For, and who they are in X-OpenVibe-User and X-OpenVibe-Subject. Cookies your app sets stay on your share\'s address.'],
['What are the limits?', 'Five open shares per person, 64 requests at once per share, 1,200 requests a minute per share, 100 MB per request body, and 10 GB a day across your shares. Nothing is stored: requests pass through.'],
@@@ -181,7 +189,7 @@ function keyBlock(share, key, baseUrl) {
</section>`;
}
--function sharePage({ share, connected, tunnel = null, visits = [], key = null, baseUrl, notice = null, error = null, now = Date.now() }) {
++function sharePage({ share, connected, tunnel = null, visits = [], key = null, baseUrl, notice = null, error = null, staff = false, now = Date.now() }) {
const closed = share.status !== 'active';
const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';
const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`
@@@ -193,7 +201,7 @@ function sharePage({ share, connected, tunnel = null, visits = [], key = null, b
const policy = closed ? '' : `<form class="cn-form cn-policy" method="post" action="/shares/${esc(share.id)}/policy">
<h2>Who can open it</h2>
<fieldset class="cn-modes"><legend class="cn-sr">Who can open it</legend>
--${Object.entries(MODE_TEXT).map(([k, v]) => `<label><input type="radio" name="mode" value="${k}"${share.mode === k ? ' checked' : ''}> ${esc(v)}</label>`).join('')}
++${modeChoices(share.mode, staff)}
</fieldset>
<label class="cn-field"><span>People (for "the people you name")</span><input name="named" maxlength="2000" value="${esc(share.named.join(', '))}" placeholder="sam, riley" autocapitalize="none" autocomplete="off" spellcheck="false"><small>OpenVibe usernames, separated by commas</small></label>
<label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(share.local_port)}"></label>
ddiff --git a/test/share.test.js b/test/share.test.js
iindex 672ae9f..502b586 100644
---- a/test/share.test.js
++++ b/test/share.test.js
@@@ -50,6 +50,12 @@ async function until(fn, ms = 3000) {
assert.strictEqual((await t.get('/api/v1/shares', { as: alex, json: { label: 'a--b', port: 80 } })).json().code, 'share.invalid_label');
assert.strictEqual((await t.get('/api/v1/shares', { as: alex, json: { label: 'agents', port: 80 } })).json().code, 'share.taken');
assert.strictEqual((await t.get('/api/v1/shares', { as: alex, json: { label: 'x', port: 70000 } })).json().code, 'share.invalid_port');
++ const everyone = await t.get('/api/v1/shares', { as: alex, json: { label: 'open', port: 80, mode: 'signin' } });
++ assert.strictEqual(everyone.status, 403, '"anyone signed in" is staff-only until the abuse check');
++ assert.strictEqual(everyone.json().code, 'share.signin_not_yet');
++ const staff = t.network.addUser('ops', { role: 'admin' });
++ const ok = await t.get('/api/v1/shares', { as: staff, json: { label: 'open', port: 80, mode: 'signin' } });
++ assert.strictEqual(ok.status, 201, ok.text);
});
await check('the gate: a browser goes to the grant, a script gets 401 with where to sign in', async () => {