From 5d08d8a7a626be152d903a1a49da110572f2f5d7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 10:08:29 -0400 Subject: [PATCH] The first account can only be made in a 30-minute setup window Family idea #5105, practice 8 (Scribe #5113), the operator's choice of setup window over a setup code. Before this, whoever reached /register first on an empty server became its admin. On a fresh server at a public address, that could be a stranger, and a new DNS name is found within minutes. Now the first registration is refused once 30 minutes have passed since the server started (create_app records STARTED_AT). A restart opens the window again. It is a constant rather than a Setting, because there is no admin yet to change one. Once an account exists it no longer matters, so existing servers are unaffected. public-hosting.md says so, and two integration tests cover both sides. Co-Authored-By: Claude Opus 5.5 --- docs/public-hosting.md | 6 ++++++ src/inkwell/app.py | 3 +++ src/inkwell/auth.py | 24 +++++++++++++++++++++--- tests/test_integration.py | 22 ++++++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/docs/public-hosting.md b/docs/public-hosting.md index 5775553..5198bb3 100644 --- a/docs/public-hosting.md +++ b/docs/public-hosting.md @@ -14,6 +14,12 @@ itself: the first account created becomes the admin *and* closes registration be it, so there is no window between "my account exists" and "I remembered to turn it off". A brand-new instance is never locked out of itself, and never left open either. +**The first account has to be made within 30 minutes of the server starting.** After +that, `/register` refuses the first account. Otherwise a fresh server on a public +address would belong to whoever found it first. If you miss the window, restart the +container and register straight away. Once an account exists, the window no longer +matters. + **Instances that predate this still need one manual flip.** The close fires when the first account is created, so a server whose admin already existed keeps whatever `allow_registration` was set to — which was **on** by default. Check **Settings → diff --git a/src/inkwell/app.py b/src/inkwell/app.py index 87d968f..3e3fadf 100644 --- a/src/inkwell/app.py +++ b/src/inkwell/app.py @@ -5,6 +5,7 @@ import logging import mimetypes import os import secrets +import time from contextlib import suppress from datetime import timedelta @@ -64,6 +65,8 @@ class _AutoSecureSessionInterface(SecureCookieSessionInterface): def create_app() -> Quart: # static_folder=None: the SPA catch-all below owns static serving. app = Quart(__name__, static_folder=None) + # When this server started, for the first-account setup window (auth.py). + app.config["STARTED_AT"] = time.monotonic() # Ephemeral/env secret so the app (and DB-free unit tests) construct without a # database. before_serving swaps in the real, DB-persisted key before serving. app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48) diff --git a/src/inkwell/auth.py b/src/inkwell/auth.py index f2a6075..4b91932 100644 --- a/src/inkwell/auth.py +++ b/src/inkwell/auth.py @@ -2,10 +2,11 @@ from __future__ import annotations import functools import logging +import time import uuid from datetime import datetime, timezone -from quart import Blueprint, g, jsonify, request, session +from quart import Blueprint, current_app, g, jsonify, request, session from sqlalchemy import delete, func, select from .common import iso @@ -45,6 +46,20 @@ SESSION_KEY = "user_id" # no key and reads as 0, the epoch every account started at. EPOCH_KEY = "epoch" MIN_PASSWORD_LEN = 8 + +# The first account can only be created this long after the server starts (family +# idea #5105, practice 8). Without it, a fresh server on a public address belongs to +# whoever reaches /register first, and a new DNS name is found within minutes. +# +# A constant, not a Setting: there is no admin yet to change one. Anyone who misses +# the window restarts the server, which opens it again; once an account exists the +# window no longer matters. Measured from `create_app` (STARTED_AT), so each start +# counts afresh. +SETUP_WINDOW_S = 30 * 60 +SETUP_CLOSED = ( + "this server's setup window has closed. Restart the server, then create the " + "first account within 30 minutes" +) DEVICE_NAME_CAP = 100 @@ -228,8 +243,11 @@ async def register(): async with session_scope() as db: user_count = await db.scalar(select(func.count()).select_from(User)) or 0 is_first = user_count == 0 - # The first account bootstraps the admin and is always allowed, even when - # registration is otherwise closed. After that, an invite lets one person in + if is_first and time.monotonic() - current_app.config["STARTED_AT"] > SETUP_WINDOW_S: + logger.warning("registration refused (setup window closed) email=%s from=%s", email, client_address()) + return json_error(SETUP_CLOSED, 403) + # The first account bootstraps the admin and is allowed, inside the setup + # window above, even when registration is otherwise closed. After that, an invite lets one person in # while it is closed (#5172). One that was offered is redeemed even while # registration is open, so the list still says who used it, and one that # doesn't hold is refused rather than ignored. diff --git a/tests/test_integration.py b/tests/test_integration.py index b7063a5..b938fdb 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -958,6 +958,28 @@ async def _admin_with_invite(app_client, **body) -> str: return (await resp.get_json())["token"] +async def test_the_first_account_is_refused_once_the_setup_window_has_closed(db): + """A fresh server on a public address must not belong to whoever finds it first + (family idea #5105, practice 8). Past the window, nobody becomes admin.""" + from inkwell.auth import SETUP_WINDOW_S + + ratelimit.reset_all() + app = create_app() + app.config["STARTED_AT"] -= SETUP_WINDOW_S + 1 + resp = await app.test_client().post( + "/api/auth/register", json={"email": "late@example.test", "password": _PASSWORD} + ) + assert resp.status_code == 403 + assert "setup window" in (await resp.get_json())["error"] + assert await db.scalar(select(func.count()).select_from(User)) == 0 + + +async def test_the_first_account_is_allowed_inside_the_setup_window(app_client, db): + resp = await app_client.post("/api/auth/register", json={"email": "prompt@example.test", "password": _PASSWORD}) + assert resp.status_code == 201 + assert (await resp.get_json())["is_admin"] is True + + async def _register(email: str, invite: str | None = None): """Register from a separate client, so the admin's session stays where it is.""" body = {"email": email, "password": _PASSWORD}