Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions bin/cli/commands/setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,33 @@ import { t } from "../i18n.mjs";
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");

async function getListCliTools() {
const { listCliTools } = await import(pathToFileURL(resolve(PROJECT_ROOT, "src/shared/constants/cliTools.ts")).href);
const { listCliTools } = await import(
pathToFileURL(resolve(PROJECT_ROOT, "src/shared/constants/cliTools.ts")).href
);
return listCliTools;
}

function wantsProviderSetup(opts) {
return opts.addProvider || Boolean(opts.provider) || Boolean(opts.apiKey);
}

async function resolvePassword(opts, prompt, nonInteractive) {
/**
* Decide which password `setup` should write, if any.
*
* `existingPassword` is the hash already stored in settings. `INITIAL_PASSWORD`
* seeds the *first* password only — the same rule the server applies in
* `src/lib/auth/managementPassword.ts`, where the stored hash wins
* (`storedPassword || getInitialPasswordValue(...)`). The CLI read it
* unconditionally (#8439), so with an `INITIAL_PASSWORD` in the environment —
* which a default npm install has, `.env` carrying `CHANGEME` — every later
* `setup` run silently replaced an operator's own password with the well-known
* default and reported "Admin password configured" (#11494).
*
* Exported for tests: the decision is asserted without a database or a TTY.
*/
export async function resolvePassword(opts, prompt, nonInteractive, existingPassword = "") {
if (opts.password) return opts.password;
if (process.env.INITIAL_PASSWORD) return process.env.INITIAL_PASSWORD;
if (!existingPassword && process.env.INITIAL_PASSWORD) return process.env.INITIAL_PASSWORD;
if (nonInteractive) return "";

const answer = await prompt.ask("Set an admin password now? [y/N]", "N");
Expand All @@ -41,9 +57,9 @@ async function resolvePassword(opts, prompt, nonInteractive) {
}

async function setupPassword(db, opts, prompt, nonInteractive) {
const password = await resolvePassword(opts, prompt, nonInteractive);
const settings = getSettings(db);
const password = await resolvePassword(opts, prompt, nonInteractive, settings.password);
if (!password) {
const settings = getSettings(db);
if (!settings.password) {
updateSettings(db, { requireLogin: false });
}
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fixes/11494-setup-initial-password.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **CLI:** stop `omniroute setup` from re-seeding `INITIAL_PASSWORD` over a password that is already set. A default npm install has `INITIAL_PASSWORD=CHANGEME` in the environment, so any later `setup` run — including one that only meant to add a provider — silently replaced the operator's own password with the well-known default and reported "Admin password configured". It now seeds the first password only, matching the server's own rule ([#11494](https://github.com/diegosouzapw/OmniRoute/issues/11494)).
36 changes: 36 additions & 0 deletions tests/unit/cli-setup-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,39 @@ test("setup command prioritizes an explicit --password flag over INITIAL_PASSWOR
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
}
});

test("a later setup run does not re-seed INITIAL_PASSWORD over the stored password", async () => {
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
await withTempEnv(async (dataDir) => {
// A default `npm install -g omniroute` ships this in the environment, so it
// is present for every run below — not only the first one (#11494).
process.env.INITIAL_PASSWORD = "CHANGEME";

const { runSetupCommand } = await import("../../bin/cli/commands/setup.mjs");

// The operator sets their own password.
assert.equal(await runSetupCommand({ nonInteractive: true, password: "operator-chosen" }), 0);

// ...then runs setup again for an unrelated reason, with no --password.
assert.equal(await runSetupCommand({ nonInteractive: true }), 0);

const db = new Database(path.join(dataDir, "storage.sqlite"));
const passwordRow = db
.prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'password'")
.get() as { value: string };
db.close();

const storedHash = JSON.parse(passwordRow.value) as string;
assert.equal(await bcrypt.compare("operator-chosen", storedHash), true);
assert.equal(
await bcrypt.compare("CHANGEME", storedHash),
false,
"the second run replaced the operator's password with the well-known default"
);
});
if (ORIGINAL_INITIAL_PASSWORD === undefined) {
delete process.env.INITIAL_PASSWORD;
} else {
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
}
});
72 changes: 72 additions & 0 deletions tests/unit/cli/setup-initial-password-11494.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";

import { resolvePassword } from "../../../bin/cli/commands/setup.mjs";

/**
* `INITIAL_PASSWORD` seeds the first dashboard password and nothing after it.
*
* A default `npm install -g omniroute` puts `INITIAL_PASSWORD=CHANGEME` in the
* environment, so before #11494 every `setup` run — including ones that only
* meant to add a provider — re-hashed `CHANGEME` over whatever the operator had
* chosen, and printed "Admin password configured" while doing it.
*/

const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;

/** A prompt that fails the test if `setup` ever reaches it. */
const noPrompt = {
ask: async () => assert.fail("resolvePassword must not prompt in non-interactive mode"),
askSecret: async () => assert.fail("resolvePassword must not prompt in non-interactive mode"),
};

async function withInitialPassword<T>(value: string, fn: () => Promise<T>): Promise<T> {
process.env.INITIAL_PASSWORD = value;
try {
return await fn();
} finally {
if (ORIGINAL_INITIAL_PASSWORD === undefined) {
delete process.env.INITIAL_PASSWORD;
} else {
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
}
}
}

// A stored bcrypt hash, i.e. an operator who already set their own password.
const STORED_HASH = "$2b$10$abcdefghijklmnopqrstuv0123456789ABCDEFGHIJKLMNOPQRSTU";

test("INITIAL_PASSWORD does not replace a password that is already set (#11494)", async () => {
const resolved = await withInitialPassword("CHANGEME", () =>
resolvePassword({}, noPrompt, true, STORED_HASH)
);

// Empty means "write nothing" — setupPassword leaves the stored hash alone.
assert.equal(resolved, "");
});

test("INITIAL_PASSWORD still seeds the first password (#8439)", async () => {
const resolved = await withInitialPassword("from-the-environment", () =>
resolvePassword({}, noPrompt, true, "")
);

assert.equal(resolved, "from-the-environment");
});

test("an explicit --password still wins over a stored one", async () => {
const resolved = await withInitialPassword("CHANGEME", () =>
resolvePassword({ password: "chosen-on-the-command-line" }, noPrompt, true, STORED_HASH)
);

assert.equal(resolved, "chosen-on-the-command-line");
});

test("no INITIAL_PASSWORD and nothing stored still writes nothing non-interactively", async () => {
const original = process.env.INITIAL_PASSWORD;
delete process.env.INITIAL_PASSWORD;
try {
assert.equal(await resolvePassword({}, noPrompt, true, ""), "");
} finally {
if (original !== undefined) process.env.INITIAL_PASSWORD = original;
}
});
Loading