app store: never assume a service is local, or on its usual port
An instance may be on another host, behind a reverse proxy on 443 under a path prefix, on a tailnet address, or on an arbitrary port because the usual one was taken. All ordinary self-hosted setups, and each one a case where assuming otherwise produces a connection that fails later with no clue why. Two places were sloppy about it. Jellyfin's placeholder read `http://localhost:8096` and Transmission's `http://localhost:9091`, which quietly teach that a service must be local and on its project's default port; both now show remote examples, and the field type says why. And nothing validated what was typed, so a bare hostname or a URL with a token in the query string was stored as-is. The rule: reject only what cannot work, normalise what is merely untidy, have no opinion about the rest. No check that the host is local, that the port matches a default, or that the scheme is https — a tailnet HTTP service is completely normal. Trailing slashes go, because `${url}/api/x` otherwise doubles the separator: accepted by some servers and 404 by others, which is the kind of difference that reproduces on one machine and not another. Query strings go, because that is where a token hides, and it would sit in a column meant for a location. Credentials in the URL are refused for the same reason — outside the encrypted secret, and in every log line that ever prints it. A missing scheme is named rather than called invalid: it is the commonest mistake, because it is what people type into a browser. Verified through the API: `memos.example.com` is blocked with the fix quoted back, and `https://memos.example.com:8443/memos/` installs and stores normalised — remote host, non-standard port, path prefix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { runSetupScript } from './run-script';
|
||||
import { startProcess } from './pm2';
|
||||
import { serviceDir } from './paths';
|
||||
import { publishAssets } from './assets';
|
||||
import { checkServiceUrl } from './url';
|
||||
|
||||
// The installer's steps, wired to the actual world.
|
||||
//
|
||||
@@ -41,6 +42,17 @@ export function createEffects(): InstallEffects {
|
||||
},
|
||||
|
||||
async connect(ctx: StepContext) {
|
||||
// Validated and normalised before anything is stored. What the user typed may be a bare hostname,
|
||||
// may carry a trailing slash that doubles a separator later, or may hide a token in a query
|
||||
// string — and none of those are visible again once written.
|
||||
const raw = ctx.values.url;
|
||||
if (raw) {
|
||||
const checked = checkServiceUrl(raw);
|
||||
if (!checked.ok) {
|
||||
return { status: 'blocked', reason: `${ctx.entry.label}: ${checked.error}` };
|
||||
}
|
||||
ctx.values.url = checked.url;
|
||||
}
|
||||
const url = ctx.values.url;
|
||||
// A provisioned install knows its own URL because the setup script printed it. An `existing` one
|
||||
// was given it in the form. Neither having produced one means we cannot reach the service, and
|
||||
|
||||
Reference in New Issue
Block a user