From 871878de41e3e09bd7267fb30d16e64d59f12b66 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 06:44:39 -0400 Subject: [PATCH] install.sh installs from your own server, not just from the forge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 325 step 5 (Scribe #3253). curl -fsSL https://notes.example.com/install.sh | sh The server serves the installer at /install.sh with its own address written into it (installer.py). Settings → Public address when set, the request's own address otherwise. The substitution is one variable, given a value that has passed a strict shape check, and the script checks it again; an address that cannot pass makes the route refuse rather than serve a script pointed elsewhere. `public_url` joins the live settings cache so the route needs no database. From a server, the script: - resolves each Linux bundle from the public /api/client/, and builds the download URL from the platform id rather than reading it from the reply; - asks for a device token (from the terminal, since stdin is the script), or takes TS_TOKEN, and sends it from a file rather than the command line; - checks the sha256 the server published before anything installs; - revokes a prompted token once the download is done; - records `install-server` for the updater (step 6) instead of the channel. The forge path is unchanged, and stays the default for the copy the forge serves. The Account page's downloads card shows the one-line command whenever the server holds a Linux client. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 5 + desktop/packaging/install.sh | 202 ++++++++++++++++++-- frontend/src/components/ClientDownloads.vue | 20 ++ src/inkwell/app.py | 2 + src/inkwell/installer.py | 105 ++++++++++ src/inkwell/settings.py | 3 + tests/test_installer.py | 123 ++++++++++++ 7 files changed, 442 insertions(+), 18 deletions(-) create mode 100644 src/inkwell/installer.py create mode 100644 tests/test_installer.py diff --git a/Dockerfile b/Dockerfile index 6264949..32a4867 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,11 @@ COPY alembic/ alembic/ # rest (client_dist.py). COPY client/ src/inkwell/client/ +# The desktop installer, served at /install.sh with this server's own address +# written into it (installer.py). The SAME script the forge serves raw, copied rather +# than moved so the operator's documented forge URL keeps working. +COPY desktop/packaging/install.sh src/inkwell/install.sh + ENV PYTHONPATH=/app/src ARG BUILD_VERSION=dev diff --git a/desktop/packaging/install.sh b/desktop/packaging/install.sh index 0cb1c8a..effc940 100755 --- a/desktop/packaging/install.sh +++ b/desktop/packaging/install.sh @@ -2,8 +2,16 @@ # # Inkwell desktop — one-command Linux installer. # +# curl -fsSL https://notes.example.com/install.sh | sh (from your server) # curl -fsSL https://git.fabledsword.com/bvandeusen/inkwell/raw/branch/dev/desktop/packaging/install.sh | sh # +# FROM A SERVER, the script installs the build that server holds — the same one its +# Account page offers — and asks for a device token to download it with, because the +# bytes are not for anyone who can reach the port. The server writes its own address +# into the copy it serves (src/inkwell/installer.py), so nothing needs editing. +# Everything below about channels is the FORGE path; a server serves one build, and +# which channel that is was decided when its image was built. +# # Two channels, the SAME two the app's own updater offers (src-tauri/src/update.rs): # stable (default) — the rolling build from every merge to `main`. # dev — the rolling build from every green push to `dev`. @@ -46,25 +54,43 @@ usage() { cat <<'USAGE' Inkwell desktop installer. - install.sh [--channel stable|dev] + install.sh [--server URL] [--channel stable|dev] - --channel stable newest build from main (default) - --channel dev rolling build from the latest green push to `dev` + --server URL install from this Inkwell server (set already when the + script came from one) + --channel stable from the forge: newest build from main (default) + --channel dev from the forge: rolling build from the latest green push to `dev` -h, --help this text -The channel can also come from TS_CHANNEL. Through a pipe, pass options after -`--`: curl -fsSL | sh -s -- --channel dev +The options can also come from TS_SERVER and TS_CHANNEL. Through a pipe, pass +them after `--`: curl -fsSL | sh -s -- --channel dev + +From a server, the download needs a device token: make one in the web app under +Account → Linked devices and paste it when asked. A token typed at the prompt is +revoked again once the download is done. TS_TOKEN supplies one without a prompt, +and is left alone, since whoever set it is managing it. USAGE } +# The address of the server that served this script. Written by that server +# (src/inkwell/installer.py rewrites exactly this line) and empty in the copy the +# forge serves, which is what keeps the forge path the default there. +TS_SERVER_DEFAULT="" + # --- channel ---------------------------------------------------------------- channel="${TS_CHANNEL:-stable}" +channel_asked="${TS_CHANNEL:-}" +server="${TS_SERVER:-$TS_SERVER_DEFAULT}" while [ $# -gt 0 ]; do case "$1" in --channel) [ $# -ge 2 ] || die "--channel needs a value (stable or dev)." - channel="$2"; shift 2 ;; - --channel=*) channel="${1#*=}"; shift ;; + channel="$2"; channel_asked="$2"; shift 2 ;; + --channel=*) channel="${1#*=}"; channel_asked="$channel"; shift ;; + --server) + [ $# -ge 2 ] || die "--server needs an address (https://…)." + server="$2"; shift 2 ;; + --server=*) server="${1#*=}"; shift ;; -h | --help) usage; exit 0 ;; *) die "unknown option: $1 (try --help)" ;; esac @@ -76,6 +102,25 @@ esac have curl || die "curl is required." +# --- server address --------------------------------------------------------- +# Checked HERE as well as by the server that wrote it: this value goes into every +# URL below, and a script should not trust a string because of where it came from. +# The same shape the server enforces — a scheme, then only characters that cannot +# mean anything to a shell or a URL parser beyond what they say. +server="${server%/}" +if [ -n "$server" ]; then + case "$server" in + http://?* | https://?*) : ;; + *) die "the server address must start with http:// or https:// (got: $server)." ;; + esac + case "$server" in + *[!A-Za-z0-9:/._~-]*) die "the server address has characters an address cannot have (got: $server)." ;; + esac + if [ -n "$channel_asked" ]; then + say "Note: a server serves the one build it holds; --channel only applies to the forge." + fi +fi + # --- architecture gate ------------------------------------------------------ # Only x86_64 is built today; arm64 will be added when the CI matrix grows. The # release-asset naming carries the arch, but since only one arch ships now we @@ -86,7 +131,99 @@ case "$arch" in *) die "Inkwell ships x86_64 Linux builds only right now (this machine: $arch)." ;; esac -# --- resolve the release for this channel ----------------------------------- +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT INT TERM + +# Every download goes through here, so the server path's token is sent on all of them +# and on nothing else. From a FILE, not the command line, where any user on the +# machine could read it out of `ps` while curl runs. +fetch() { + if [ -n "$server" ]; then + curl -fSL -H @"$tmp/auth" -o "$2" "$1" + else + curl -fSL -o "$2" "$1" + fi +} + +# The download against the digest its server published, BEFORE it reaches pacman, +# dpkg or a menu entry: a truncated file installs as something broken rather than +# failing. The forge path publishes no digest per bundle, so it passes an empty one +# and this checks nothing there; that is the forge path exactly as it always was. +verify() { + [ -n "$2" ] || return 0 + if have sha256sum; then + got="$(sha256sum "$1" | cut -d' ' -f1)" + elif have shasum; then + got="$(shasum -a 256 "$1" | cut -d' ' -f1)" + else + die "can't check the download: neither sha256sum nor shasum is installed." + fi + [ "$got" = "$2" ] || die "the download doesn't match what the server published (sha256 $got, expected $2). Nothing was installed." +} + +pkg_sha=""; deb_sha=""; appimage_sha="" + +if [ -n "$server" ]; then +# --- resolve from a server ---------------------------------------------------- +# `/api/client/` is public, so what the server holds is known before any +# token is asked for. The download URL is BUILT here from the platform id, never read +# from the reply — the same rule the apps follow (core/src/sync/client.rs): a reply +# that named some other host would otherwise be fetched, with the token attached. +say "Finding the Inkwell build $server holds…" + +# One string field out of the flat JSON object the server returns. sed rather than +# a parser, for the same reason as the forge path's grep: no jq on most machines. +jfield() { + printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1 +} +meta() { curl -fsS "$server/api/client/$1" 2>/dev/null || true; } + +pkg_meta="$(meta linux-pacman)" +deb_meta="$(meta linux-deb)" +appimage_meta="$(meta linux-appimage)" +[ -n "$pkg_meta" ] || [ -n "$deb_meta" ] || [ -n "$appimage_meta" ] || + die "$server has no Linux client to install (or isn't an Inkwell server — is the address right?)." + +pkg_url=""; deb_url=""; appimage_url="" +if [ -n "$pkg_meta" ]; then + pkg_url="$server/api/client/linux-pacman/download"; pkg_sha="$(jfield "$pkg_meta" sha256)" +fi +if [ -n "$deb_meta" ]; then + deb_url="$server/api/client/linux-deb/download"; deb_sha="$(jfield "$deb_meta" sha256)" +fi +if [ -n "$appimage_meta" ]; then + appimage_url="$server/api/client/linux-appimage/download"; appimage_sha="$(jfield "$appimage_meta" sha256)" +fi +# One build, four bundles: any of them names the version. +version="$(jfield "$pkg_meta$deb_meta$appimage_meta" version)" +say "Installing ${version:-unknown} from $server" + +# The token, from the environment or from the person at the keyboard. Read from the +# TERMINAL, not stdin: through `curl | sh`, stdin is this script. +token="${TS_TOKEN:-}" +prompted="" +if [ -z "$token" ]; then + { [ -r /dev/tty ] && [ -w /dev/tty ]; } || + die "the download needs a device token and there's no terminal to ask on; set TS_TOKEN." + printf 'A device token is needed to download from %s.\n' "$server" > /dev/tty + printf 'Make one in the web app under Account → Linked devices (it is revoked once the download is done).\n' > /dev/tty + printf 'Token: ' > /dev/tty + stty -echo < /dev/tty 2>/dev/null || true + IFS= read -r token < /dev/tty || true + stty echo < /dev/tty 2>/dev/null || true + printf '\n' > /dev/tty + prompted=1 +fi +[ -n "$token" ] || die "no token given." +# The server's tokens are URL-safe base64. Anything else is a paste gone wrong, and a +# newline in it would be a second header. +case "$token" in + *[!A-Za-z0-9_-]*) die "that doesn't look like a device token (they're letters, digits, - and _)." ;; +esac +( umask 077; printf 'Authorization: Bearer %s\n' "$token" > "$tmp/auth" ) + +else +# --- resolve from the forge, for this channel -------------------------------- say "Finding the latest Inkwell build on the $channel channel…" # ONE lookup, both channels. Each is a release whose tag never moves and whose assets @@ -117,9 +254,19 @@ version="$(printf '%s' "$json" | grep -oE '"tag_name":"[^"]+"' | head -1 | sed - [ -n "$appimage_url" ] || [ -n "$deb_url" ] || [ -n "$pkg_url" ] || die "the $channel release (${version:-unknown}) has no installable Linux asset." say "Installing ${version:-unknown} from the $channel channel" +fi -tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT INT TERM +# A token typed at the prompt was made for this one install; once the bytes are here +# it has nothing left to do, so it goes. Best-effort: the install does not depend on +# it, and the person is told if it is still live. +revoke_token() { + [ -n "$server" ] && [ -n "$prompted" ] || return 0 + if curl -fsS -o /dev/null -X DELETE -H @"$tmp/auth" "$server/api/auth/devices/self" 2>/dev/null; then + say "Revoked the download token." + else + say "Couldn't revoke the download token; remove it under Account → Linked devices." + fi +} # Tell the app which channel it was installed from. The installer is the only thing # that knows, and without this the app kept its own `stable` default and a dev install @@ -133,11 +280,21 @@ trap 'rm -rf "$tmp"' EXIT INT TERM # # The directory is Tauri's app-data dir for identifier com.fabledsword.inkwell; # both sides hardcode it, so a change to the identifier has to change both. +# +# From a server, the sibling `install-server` is written instead: which server the app +# came from, for the updater to follow (milestone 325, step 6). The channel marker is +# left alone on that path, because a server's channel is whatever its image was built +# with and nothing here can know it. record_channel() { marker_dir="${XDG_DATA_HOME:-$HOME/.local/share}/com.fabledsword.inkwell" # Best-effort: a failure here costs the channel setting, not the install, and a # native install run as root would only be writing into root's home anyway. - mkdir -p "$marker_dir" 2>/dev/null && printf '%s\n' "$channel" > "$marker_dir/install-channel" 2>/dev/null || true + mkdir -p "$marker_dir" 2>/dev/null || return 0 + if [ -n "$server" ]; then + printf '%s\n' "$server" > "$marker_dir/install-server" 2>/dev/null || true + else + printf '%s\n' "$channel" > "$marker_dir/install-channel" 2>/dev/null || true + fi } # Both native paths install system-wide, so they need root. Resolved once here @@ -155,7 +312,9 @@ need_root() { # than letting someone discover it from a greyed-out button. native_update_note() { printf ' A package-manager install can'\''t update itself in-app.\n' - if [ "$channel" = "dev" ]; then + if [ -n "$server" ]; then + printf ' Re-run this script from %s to move to the build it holds.\n' "$server" + elif [ "$channel" = "dev" ]; then printf ' Re-run this script with --channel dev to move to a newer dev build.\n' else printf ' Re-run this script to move to a newer release.\n' @@ -171,8 +330,11 @@ if have pacman && [ -n "$pkg_url" ]; then say "Arch-family system detected — installing the native pacman package" # Keep the published filename: pacman -U expects a *.pkg.tar.* name and refuses # a file that doesn't look like a package, whatever its actual contents. - pkg_file="$tmp/$(basename "$pkg_url")" - curl -fSL -o "$pkg_file" "$pkg_url" + # From a server the URL ends in `/download`, so the name comes from the platform. + if [ -n "$server" ]; then pkg_file="$tmp/inkwell.pkg.tar.zst"; else pkg_file="$tmp/$(basename "$pkg_url")"; fi + fetch "$pkg_url" "$pkg_file" + verify "$pkg_file" "$pkg_sha" + revoke_token need_root $sudo pacman -U --noconfirm "$pkg_file" record_channel @@ -184,7 +346,9 @@ fi # --- native .deb path (Debian/Ubuntu) --------------------------------------- if have dpkg && have apt-get && [ -n "$deb_url" ]; then say "Debian-family system detected — installing the native .deb" - curl -fSL -o "$tmp/inkwell.deb" "$deb_url" + fetch "$deb_url" "$tmp/inkwell.deb" + verify "$tmp/inkwell.deb" "$deb_sha" + revoke_token need_root # apt-get resolves the .deb's dependencies (webkit2gtk etc.). dpkg is the # fallback if this apt is too old for local-file installs — it leaves the deps @@ -207,8 +371,10 @@ say "Installing the de-bundled AppImage (user-local, no sudo)" apps_dir="$HOME/Applications" dest="$apps_dir/Inkwell.AppImage" mkdir -p "$apps_dir" -say "Downloading $(basename "$appimage_url")…" -curl -fSL -o "$tmp/Inkwell.AppImage" "$appimage_url" +say "Downloading the AppImage…" +fetch "$appimage_url" "$tmp/Inkwell.AppImage" +verify "$tmp/Inkwell.AppImage" "$appimage_sha" +revoke_token chmod +x "$tmp/Inkwell.AppImage" mv -f "$tmp/Inkwell.AppImage" "$dest" @@ -257,7 +423,7 @@ say "Installed to $dest" printf ' Launch it from your application menu, or run \033[1minkwell\033[0m' printf ' (if ~/.local/bin is on your PATH).\n' # This is the one path where the app can update itself, so say what it will follow. -if [ "$channel" = "dev" ]; then +if [ -z "$server" ] && [ "$channel" = "dev" ]; then printf ' In-app updates will follow the \033[1mdev\033[0m channel.' printf ' Change it in Sync → App updates.\n' fi diff --git a/frontend/src/components/ClientDownloads.vue b/frontend/src/components/ClientDownloads.vue index 1047e9a..8a70e33 100644 --- a/frontend/src/components/ClientDownloads.vue +++ b/frontend/src/components/ClientDownloads.vue @@ -101,6 +101,13 @@ const missingPlatform = computed(() => !lead.value.length && (family === "mac" || family === "ios") ? FAMILY_TITLE[family] : "", ); +// The one-command Linux install (milestone 325 step 5), shown wherever the server +// holds a Linux client to install. Against the address this page was reached on, +// which is the one the person can evidently reach; the script the server sends +// back carries Settings → Public address when that is set. +const hasLinux = computed(() => Object.keys(config.clients).some((id) => id.startsWith("linux-"))); +const installCommand = `curl -fsSL ${window.location.origin}/install.sh | sh`; + // One decimal below 10 MB, none above: these sit in one list where a 2.7 MB // package and a 95 MB AppImage are compared, and "3 MB" next to "95 MB" loses the // only distinction that matters at the small end. @@ -163,5 +170,18 @@ function readableSize(bytes: number): string { + +
+

+ Or on Linux, from a terminal. It picks the right package for the system and asks + for a token from Linked devices to download it with. +

+ + {{ installCommand }} +
diff --git a/src/inkwell/app.py b/src/inkwell/app.py index ff8c83e..87d968f 100644 --- a/src/inkwell/app.py +++ b/src/inkwell/app.py @@ -14,6 +14,7 @@ from quart.sessions import SecureCookieSessionInterface from .accounts_api import bp as accounts_bp from .auth import bp as auth_bp from .client_dist import advertisement as client_advertisement, bp as client_bp +from .installer import bp as installer_bp from .groups_api import bp as groups_bp from .config import Config from .db import session_scope @@ -100,6 +101,7 @@ def create_app() -> Quart: app.register_blueprint(shares_bp) app.register_blueprint(sync_bp) app.register_blueprint(client_bp) + app.register_blueprint(installer_bp) @app.before_serving async def _bootstrap() -> None: diff --git a/src/inkwell/installer.py b/src/inkwell/installer.py new file mode 100644 index 0000000..4f0d93b --- /dev/null +++ b/src/inkwell/installer.py @@ -0,0 +1,105 @@ +"""`GET /install.sh` — the desktop installer, pointed at the server that served it. + + curl -fsSL https://notes.example.com/install.sh | sh + +## Why the server templates it + +`curl | sh` gives a script no way to learn where it came from: no argv, no referrer, +nothing. So the server writes its own address into the copy it hands out, and the +script installs from there without being told. Anything else means a self-hoster +editing a URL by hand, which is the friction `client_dist.py` exists to remove. + +## Which way it fails + +A server that can put arbitrary text into a script a person pipes to `sh` is the +whole attack. So the substitution is ONE variable, `TS_SERVER_DEFAULT`, given a value +that has already passed [SAFE_ADDRESS] — no quote, no space, no `$`, nothing a shell +reads as anything but characters — and the script checks the shape again before it +uses it. If the address cannot pass, the route refuses rather than serving a script +that would install from somewhere else. + +## Where the script comes from + +ONE script. The forge serves `desktop/packaging/install.sh` raw, and that is how the +operator installs. The image carries a copy beside this module (the Dockerfile copies +it in), and a source checkout reads the original. Same bytes either way; this module +changes exactly one line of them. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from quart import Blueprint, Response, request + +from .proxy import is_https +from .responses import json_error +from .settings import live + +bp = Blueprint("installer", __name__) + +# The copy the image carries (Dockerfile), and the original in a source checkout. +BAKED_SCRIPT = Path(__file__).resolve().parent / "install.sh" +SOURCE_SCRIPT = Path(__file__).resolve().parents[2] / "desktop" / "packaging" / "install.sh" + +# The one line the server rewrites. The script carries it exactly like this, and +# `render` refuses a script that does not, rather than serving one with no address in. +PLACEHOLDER = 'TS_SERVER_DEFAULT=""' + +# What a server address may look like before it goes into a shell script: a scheme, +# a host name or IPv4 address, an optional port and an optional path. Deliberately +# narrower than a URL. Every character it admits is inert inside single quotes, and +# an address it turns away (an IPv6 literal, a path with `%` in it) is answered by +# setting Settings → Public address, not by widening this. +SAFE_ADDRESS = re.compile(r"https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~/-]*)?") + + +def script_text() -> str | None: + """The installer as published, or None when this server has no copy of it.""" + for path in (BAKED_SCRIPT, SOURCE_SCRIPT): + try: + return path.read_text(encoding="utf-8") + except OSError: + continue + return None + + +def server_address(public_url: str, scheme: str, host: str) -> str | None: + """Where the installer should fetch from, or None if no safe answer exists. + + Settings → Public address when it is set: it is the address the operator says + people use, and behind a reverse proxy the request's own Host may be an internal + one. Otherwise the address this request arrived on, which is the one the person + running `curl` just reached. + """ + address = (public_url or f"{scheme}://{host}").rstrip("/") + return address if SAFE_ADDRESS.fullmatch(address) else None + + +def render(script: str, address: str) -> str | None: + """`script` with `address` as its default server, or None if it cannot be done safely.""" + if not SAFE_ADDRESS.fullmatch(address) or script.count(PLACEHOLDER) != 1: + return None + return script.replace(PLACEHOLDER, f"TS_SERVER_DEFAULT='{address}'") + + +@bp.get("/install.sh") +async def install_script(): + """Public, like the client metadata: the script holds nothing secret, and the + bytes it goes on to fetch are behind a device token.""" + script = script_text() + if script is None: + return json_error("this server has no installer", 404) + address = server_address(live("public_url"), "https" if is_https() else "http", request.host) + if address is None: + return json_error( + "this server cannot state its own address safely; set Settings → Public address", + 500, + ) + body = render(script, address) + if body is None: + return json_error("this server's installer is not the shape it expects", 500) + # Plain text so a browser shows it rather than downloading it: "read it before you + # pipe it" is the script's own advice. + return Response(body, mimetype="text/plain") diff --git a/src/inkwell/settings.py b/src/inkwell/settings.py index a05e446..44f9de0 100644 --- a/src/inkwell/settings.py +++ b/src/inkwell/settings.py @@ -316,6 +316,9 @@ async def get_admin_settings(db) -> list[dict]: # and again whenever an admin saves. Same live-update contract `session_ttl_days` # already has in settings_api.py. _LIVE_KEYS = ( + # Read by GET /install.sh, which writes it into the installer it serves + # (installer.py) and must not need a database to answer. + "public_url", "trusted_proxy_hops", "signin_limit_per_account", "signin_limit_per_address", diff --git a/tests/test_installer.py b/tests/test_installer.py new file mode 100644 index 0000000..4430a7c --- /dev/null +++ b/tests/test_installer.py @@ -0,0 +1,123 @@ +import pytest + +from inkwell import installer, settings +from inkwell.app import create_app +from inkwell.installer import PLACEHOLDER, SOURCE_SCRIPT, render, server_address + +# DB-free, like test_client_dist: the route reads `public_url` from the live cache +# rather than from the database, which is what makes it testable here. + +SCRIPT = f"#!/bin/sh\nset -eu\n{PLACEHOLDER}\nserver=\"${{TS_SERVER:-$TS_SERVER_DEFAULT}}\"\n" + + +@pytest.fixture(autouse=True) +def _defaults(): + settings.reset_live() + yield + settings.reset_live() + + +@pytest.fixture +def app(): + return create_app() + + +def test_the_published_script_carries_the_line_the_server_rewrites_exactly_once(): + """The forge-served script and the server-served one are the same file; this is + the one line that differs. Edit it and the server stops serving rather than + serving a script with no address in it — this test is what says so first.""" + assert SOURCE_SCRIPT.read_text(encoding="utf-8").count(PLACEHOLDER) == 1 + + +def test_render_writes_the_address_into_the_one_variable(): + body = render(SCRIPT, "https://notes.example.com") + assert "TS_SERVER_DEFAULT='https://notes.example.com'" in body + assert PLACEHOLDER not in body + # Nothing else moved. + assert body.replace("TS_SERVER_DEFAULT='https://notes.example.com'", PLACEHOLDER) == SCRIPT + + +@pytest.mark.parametrize( + "address", + [ + "https://notes.example.com/sub/path", + "http://192.168.1.20:5000", + "https://notes.example.com:8443", + ], +) +def test_an_ordinary_address_is_accepted(address): + assert render(SCRIPT, address) is not None + + +@pytest.mark.parametrize( + "address", + [ + # Each is a way out of the single quotes, or a second command. + "https://x.example.com'; rm -rf ~; '", + "https://x.example.com/$(id)", + "https://x.example.com/`id`", + "https://x.example.com\nrm -rf ~", + "https://x.example.com/a b", + "https://x.example.com/;id", + # Not an address at all. + "ftp://x.example.com", + "x.example.com", + "", + ], +) +def test_an_address_that_could_mean_something_to_a_shell_is_refused(address): + assert render(SCRIPT, address) is None + + +@pytest.mark.parametrize("script", ["#!/bin/sh\n", SCRIPT + PLACEHOLDER + "\n"]) +def test_a_script_without_exactly_one_placeholder_is_not_served(script): + assert render(script, "https://notes.example.com") is None + + +def test_the_public_address_setting_beats_the_request_host(): + """Behind a reverse proxy the Host this request arrived with may be an internal + one; Settings → Public address is what the operator says people use.""" + assert server_address("https://notes.example.com", "http", "inkwell:5000") == "https://notes.example.com" + + +def test_without_the_setting_the_request_address_is_used(): + assert server_address("", "https", "notes.example.com") == "https://notes.example.com" + + +def test_a_trailing_slash_is_dropped(): + assert server_address("https://notes.example.com/", "http", "ignored") == "https://notes.example.com" + + +def test_a_host_header_that_is_not_an_address_yields_none(): + assert server_address("", "http", "evil.example.com'$(id)") is None + + +async def test_the_route_serves_the_script_pointed_at_this_server(app): + resp = await app.test_client().get("/install.sh", headers={"Host": "notes.example.com"}) + assert resp.status_code == 200 + assert resp.mimetype == "text/plain" + body = await resp.get_data(as_text=True) + assert "TS_SERVER_DEFAULT='http://notes.example.com'" in body + assert body.startswith("#!/bin/sh") + + +async def test_the_route_uses_the_public_address_when_one_is_set(app, monkeypatch): + monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com") + resp = await app.test_client().get("/install.sh", headers={"Host": "inkwell:5000"}) + assert "TS_SERVER_DEFAULT='https://notes.example.com'" in await resp.get_data(as_text=True) + + +async def test_the_route_refuses_rather_than_serve_an_unsafe_address(app, monkeypatch): + # A Public address saved before it was validated this strictly: Settings only + # checks the scheme, so this is reachable. + monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com/'$(id)'") + resp = await app.test_client().get("/install.sh", headers={"Host": "notes.example.com"}) + assert resp.status_code == 500 + assert "TS_SERVER_DEFAULT" not in await resp.get_data(as_text=True) + + +async def test_a_server_with_no_copy_of_the_script_404s(app, monkeypatch, tmp_path): + monkeypatch.setattr(installer, "BAKED_SCRIPT", tmp_path / "absent") + monkeypatch.setattr(installer, "SOURCE_SCRIPT", tmp_path / "also-absent") + resp = await app.test_client().get("/install.sh") + assert resp.status_code == 404