mesh/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/check pass 1cc54959afcf on a85d90374195 | unit 1994 run, 2322 kept, 5 skipped | integration 2016 run, 2341 kept, 9 skipped | over bound: unit, in…
Resetting Nextcloud's brute-force record for the docker gateway cleared it for every client behind that address: someone guessing passwords could drive the throttle to 429 and have the module's poll wipe it. A throttle is no evidence the password is wrong, so a 429 is now backed off only, and the back-off outlasts the throttle. The account's existence is read from user:info's exit status, since user:list pages.
306 lines
13 KiB
TypeScript
306 lines
13 KiB
TypeScript
// novox/hq issue 1253: the module's own Nextcloud account is made to match its own secret when
|
|
// Nextcloud refuses the login, and a refusal that stays is said once and backed off, never asked
|
|
// again every minute into Nextcloud's brute-force throttle. Run against the compiled module, the
|
|
// way the runtime loads it.
|
|
|
|
import { test, mock } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { Backoff, LoginRefusedError, NextcloudClient, pollSharesGuarded } from "../dist/client.js";
|
|
|
|
const PASSWORD = "the-modules-own-secret";
|
|
const REFUSED_XML = `<?xml version="1.0"?>\n<ocs>\n <meta>\n <status>failure</status>\n <statuscode>997</statuscode>\n <message>Unauthorised</message>\n </meta>\n <data/>\n</ocs>\n`;
|
|
const SHARES = { ocs: { data: [{ id: 7, path: "/Photos", share_type: 3, share_with: "", uid_owner: "jochens" }] } };
|
|
|
|
function client(user = "mesh-admin"): any {
|
|
return new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", user, PASSWORD);
|
|
}
|
|
|
|
/** A stand-in Nextcloud: answers `statuses` in turn (the last one repeating) and counts the asks. */
|
|
function standInFetch(statuses: number[]) {
|
|
let asked = 0;
|
|
const impl = async (): Promise<Response> => {
|
|
const status = statuses[Math.min(asked, statuses.length - 1)];
|
|
asked += 1;
|
|
if (status === 200) return new Response(JSON.stringify(SHARES), { status });
|
|
return new Response(REFUSED_XML, { status });
|
|
};
|
|
return { impl, asked: () => asked };
|
|
}
|
|
|
|
/** A stand-in occ: knows whether the account exists, records every call and every env passed. */
|
|
function standInOcc(accountExists: boolean, opts: { failWrite?: string } = {}) {
|
|
const calls: string[][] = [];
|
|
const envs: Array<Record<string, string> | undefined> = [];
|
|
const impl = (args: string[], occOpts: { env?: Record<string, string> } = {}): string => {
|
|
calls.push(args);
|
|
envs.push(occOpts.env);
|
|
if (args[0] === "user:info") {
|
|
// As the real occ() throws it: occ's stdout as the message, its exit status alongside.
|
|
if (!accountExists) throw Object.assign(new Error("user not found"), { status: 1 });
|
|
return "{}";
|
|
}
|
|
if (["user:add", "user:resetpassword"].includes(args[0])) {
|
|
if (opts.failWrite) throw new Error(opts.failWrite);
|
|
return "";
|
|
}
|
|
throw new Error("unexpected occ call: " + args.join(" "));
|
|
};
|
|
return { calls, envs, impl };
|
|
}
|
|
|
|
/** The occ calls that write: anything but a read of the account. */
|
|
const writes = (calls: string[][]) => calls.filter((a) => a[0] !== "user:info");
|
|
|
|
// 1253/AC1: given an adopted instance without the mesh's account, when Nextcloud refuses the
|
|
// login, then the account is added (no admin group) with the module's own secret, by OC_PASS and
|
|
// never on an argv, and the shares are listed; Nextcloud's brute-force record is left alone.
|
|
test("a refused login adds the missing account, password by environment, and lists the shares", async () => {
|
|
const c = client();
|
|
const occ = standInOcc(false);
|
|
mock.method(c, "occ", occ.impl);
|
|
const web = standInFetch([401, 200]);
|
|
const f = mock.method(globalThis, "fetch", web.impl);
|
|
try {
|
|
const shares = await c.listShares();
|
|
assert.equal(web.asked(), 2);
|
|
assert.deepEqual(shares.map((s: any) => s.id), ["7"]);
|
|
const add = occ.calls.find((a) => a[0] === "user:add");
|
|
assert.ok(add, "user:add ran");
|
|
assert.ok(add.includes("--password-from-env") && add.includes("mesh-admin"));
|
|
assert.ok(!add.some((a) => a.includes("group")), "no admin group: least privilege");
|
|
assert.deepEqual(occ.calls.map((a) => a[0]), ["user:info", "user:add"]);
|
|
assert.equal(occ.envs[occ.calls.indexOf(add)]?.OC_PASS, PASSWORD);
|
|
assert.ok(!occ.calls.flat().some((a) => a.includes(PASSWORD)), "the password is on no argv");
|
|
} finally {
|
|
f.mock.restore();
|
|
}
|
|
});
|
|
|
|
// 1253/AC2: given the account exists with another password, when Nextcloud refuses the login,
|
|
// then its password is set to the module's own secret, in no group.
|
|
test("a refused login resets an existing account's password, in no group", async () => {
|
|
const c = client();
|
|
const occ = standInOcc(true);
|
|
mock.method(c, "occ", occ.impl);
|
|
const f = mock.method(globalThis, "fetch", standInFetch([401, 200]).impl);
|
|
try {
|
|
await c.listShares();
|
|
assert.deepEqual(occ.calls.map((a) => a[0]), ["user:info", "user:resetpassword"]);
|
|
assert.equal(occ.envs[1]?.OC_PASS, PASSWORD);
|
|
assert.ok(!occ.calls.flat().some((a) => a.includes(PASSWORD)));
|
|
} finally {
|
|
f.mock.restore();
|
|
}
|
|
});
|
|
|
|
// 1253/AC3: given the login is still refused after the account was made right, then
|
|
// listShares throws LoginRefusedError naming the status and the account, never the password or
|
|
// the whole XML, and a later call does not repair the account again.
|
|
test("a refusal after the repair throws LoginRefusedError once-repaired, short and without the password", async () => {
|
|
const c = client();
|
|
const occ = standInOcc(true);
|
|
mock.method(c, "occ", occ.impl);
|
|
const web = standInFetch([401]);
|
|
const f = mock.method(globalThis, "fetch", web.impl);
|
|
try {
|
|
await assert.rejects(c.listShares(), (err: any) => {
|
|
assert.ok(err instanceof LoginRefusedError);
|
|
assert.equal(err.status, 401);
|
|
assert.equal(err.message, "Nextcloud refused the login of mesh-admin (401: Unauthorised)");
|
|
assert.ok(!err.message.includes(PASSWORD));
|
|
return true;
|
|
});
|
|
await assert.rejects(c.listShares(), LoginRefusedError);
|
|
assert.equal(occ.calls.filter((a) => a[0] === "user:resetpassword").length, 1, "repaired once");
|
|
assert.equal(web.asked(), 3);
|
|
} finally {
|
|
f.mock.restore();
|
|
}
|
|
});
|
|
|
|
// 1253/AC3b: a throttle (429) is no evidence the password is wrong: two cycles of 429 then 200
|
|
// run no occ write, and the 429s are LoginRefusedError for the back-off.
|
|
test("a throttle (429) is backed off, never repaired", async () => {
|
|
const c = client();
|
|
const occ = standInOcc(true);
|
|
mock.method(c, "occ", occ.impl);
|
|
const f = mock.method(globalThis, "fetch", standInFetch([429, 200, 429, 200]).impl);
|
|
try {
|
|
for (let cycle = 0; cycle < 2; cycle++) {
|
|
await assert.rejects(c.listShares(), (err: any) => err instanceof LoginRefusedError && err.status === 429);
|
|
assert.equal((await c.listShares()).length, 1);
|
|
}
|
|
assert.deepEqual(occ.calls, [], "no occ call at all, so no write");
|
|
} finally {
|
|
f.mock.restore();
|
|
}
|
|
});
|
|
|
|
// 1253/AC3c: a 401 runs exactly one repair.
|
|
test("a 401 runs exactly one repair", async () => {
|
|
const c = client();
|
|
const occ = standInOcc(true);
|
|
mock.method(c, "occ", occ.impl);
|
|
const f = mock.method(globalThis, "fetch", standInFetch([401, 200]).impl);
|
|
try {
|
|
await c.listShares();
|
|
assert.equal(writes(occ.calls).length, 1);
|
|
} finally {
|
|
f.mock.restore();
|
|
}
|
|
});
|
|
|
|
// 1253/AC4: given the login stays refused, when the loop ticks every minute, then the refusal
|
|
// is said once, the shares are asked only after 5, 10, 20, 40 and then every 60 minutes, the
|
|
// ceiling is said once more, and one line says when the login is accepted again.
|
|
test("a refusal that stays is said once, backed off to an hour, and its end is said", async () => {
|
|
const said: string[] = [];
|
|
const err = mock.method(console, "error", (...a: unknown[]) => { said.push(a.join(" ")); });
|
|
const log = mock.method(console, "log", (...a: unknown[]) => { said.push(a.join(" ")); });
|
|
try {
|
|
let refused = true;
|
|
let polls = 0;
|
|
const askedAt: number[] = [];
|
|
let clock = 0;
|
|
const poll = async (): Promise<void> => {
|
|
polls += 1;
|
|
askedAt.push(clock / 60_000);
|
|
if (refused) throw new LoginRefusedError(401, "mesh-admin", "Unauthorised");
|
|
};
|
|
const backoff = new Backoff(5 * 60_000, 60 * 60_000);
|
|
for (let minute = 0; minute <= 200; minute++) {
|
|
clock = minute * 60_000;
|
|
await pollSharesGuarded({} as any, backoff, poll, () => clock);
|
|
}
|
|
assert.deepEqual(askedAt, [0, 5, 15, 35, 75, 135, 195]);
|
|
assert.equal(said.length, 2, said.join("\n"));
|
|
assert.match(said[0], /refused the login of mesh-admin \(401: Unauthorised\); not asking again for 5 min/);
|
|
assert.match(said[1], /still; asking once an hour/);
|
|
|
|
refused = false;
|
|
clock = 260 * 60_000;
|
|
await pollSharesGuarded({} as any, backoff, poll, () => clock);
|
|
assert.equal(said[2], "[nextcloud] shares: the login is accepted again");
|
|
const before = polls;
|
|
clock += 60_000;
|
|
await pollSharesGuarded({} as any, backoff, poll, () => clock);
|
|
assert.equal(polls, before + 1, "every tick again once it passes");
|
|
assert.equal(said.length, 3);
|
|
} finally {
|
|
err.mock.restore();
|
|
log.mock.restore();
|
|
}
|
|
});
|
|
|
|
// 1253/AC5: occ passes an environment variable by name alone, its value through the docker
|
|
// CLI's environment, so the password is on no argv.
|
|
test("occ passes OC_PASS by name, its value only in docker's environment", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "nextcloud-1253-"));
|
|
const record = join(dir, "record");
|
|
writeFileSync(join(dir, "docker"), `#!/bin/sh\nprintf '%s\\n' "$*" > ${record}\nprintf '%s' "$OC_PASS" >> ${record}\n`);
|
|
chmodSync(join(dir, "docker"), 0o755);
|
|
const path = process.env.PATH;
|
|
process.env.PATH = `${dir}:${path}`;
|
|
try {
|
|
client().occ(["user:resetpassword", "--password-from-env", "mesh-admin"], { env: { OC_PASS: PASSWORD } });
|
|
} finally {
|
|
process.env.PATH = path;
|
|
}
|
|
const [argv, env] = readFileSync(record, "utf8").split("\n");
|
|
assert.equal(argv, "exec -e OC_PASS -u www-data nextcloud php occ user:resetpassword --password-from-env mesh-admin");
|
|
assert.equal(env, PASSWORD);
|
|
});
|
|
|
|
// 1253/AC6: an empty setting is the manifest's default, never an empty account name.
|
|
test("an empty user setting falls back to the manifest's account", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "nextcloud-1253-"));
|
|
writeFileSync(join(dir, "config.json"), JSON.stringify({ user: "", container: "", url: "" }));
|
|
writeFileSync(join(dir, "admin.secret"), PASSWORD);
|
|
const c: any = NextcloudClient.fromEnv({
|
|
MESH_NEXTCLOUD_CONFIG_FILE: join(dir, "config.json"),
|
|
MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE: join(dir, "admin.secret"),
|
|
MESH_NEXTCLOUD_ADMIN_USER: "mesh-admin",
|
|
MESH_NEXTCLOUD_URL: "http://127.0.0.1:20003",
|
|
});
|
|
assert.equal(c.adminUser, "mesh-admin");
|
|
assert.equal(c.container, "nextcloud");
|
|
assert.equal(c.ocsUrl, "http://127.0.0.1:20003");
|
|
});
|
|
|
|
// 1253/AC7: the repair touches only the module's own account: a refusal under a user setting
|
|
// naming another account (the operator's) runs no occ write and throws LoginRefusedError.
|
|
test("a refusal under another account's name writes nothing", async () => {
|
|
const c = client("jochens");
|
|
const occ = standInOcc(true);
|
|
mock.method(c, "occ", occ.impl);
|
|
const web = standInFetch([401]);
|
|
const f = mock.method(globalThis, "fetch", web.impl);
|
|
try {
|
|
await assert.rejects(c.listShares(), (err: any) => {
|
|
assert.ok(err instanceof LoginRefusedError);
|
|
assert.match(err.message, /refused the login of jochens .*not repaired: only mesh-admin/);
|
|
return true;
|
|
});
|
|
assert.deepEqual(writes(occ.calls), []);
|
|
} finally {
|
|
f.mock.restore();
|
|
}
|
|
});
|
|
|
|
// 1253/AC8: a repair whose occ write fails is said as a LoginRefusedError carrying occ's own
|
|
// message, so the back-off's one loud line says why.
|
|
test("a failing repair carries occ's message into LoginRefusedError", async () => {
|
|
const c = client();
|
|
mock.method(c, "occ", standInOcc(false, { failWrite: "The password policy refused the password" }).impl);
|
|
const f = mock.method(globalThis, "fetch", standInFetch([401]).impl);
|
|
try {
|
|
await assert.rejects(c.listShares(), (err: any) =>
|
|
err instanceof LoginRefusedError && /repair failed: The password policy refused the password/.test(err.message));
|
|
} finally {
|
|
f.mock.restore();
|
|
}
|
|
});
|
|
|
|
/** A fake docker on PATH: answers occ like Nextcloud (user:info's refusal on stdout, exit 1). */
|
|
function fakeDocker(): { dir: string; record: string } {
|
|
const dir = mkdtempSync(join(tmpdir(), "nextcloud-1253-docker-"));
|
|
const record = join(dir, "record");
|
|
writeFileSync(join(dir, "docker"), [
|
|
"#!/bin/sh",
|
|
`printf '%s\\n' "$*" >> ${record}`,
|
|
'case "$*" in',
|
|
" *' occ user:info '*) echo 'user not found'; exit 1 ;;",
|
|
"esac",
|
|
"exit 0",
|
|
"",
|
|
].join("\n"));
|
|
chmodSync(join(dir, "docker"), 0o755);
|
|
return { dir, record };
|
|
}
|
|
|
|
function withPath<T>(dir: string, fn: () => T): T {
|
|
const path = process.env.PATH;
|
|
process.env.PATH = `${dir}:${path}`;
|
|
try { return fn(); } finally { process.env.PATH = path; }
|
|
}
|
|
|
|
// 1253/AC9: occ's own refusal on stdout with an empty stderr reaches the error, and the real
|
|
// occ() through a docker answering like Nextcloud makes the missing account.
|
|
test("occ says a refusal printed on stdout, and the real occ makes the missing account", () => {
|
|
const docker = fakeDocker();
|
|
const c = new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", "mesh-admin", PASSWORD);
|
|
withPath(docker.dir, () => {
|
|
assert.throws(() => c.occ(["user:info", "mesh-admin", "--output=json"]), /user not found/);
|
|
const did = c.ensureAdminAccount();
|
|
assert.equal(did, "created");
|
|
});
|
|
const lines = readFileSync(docker.record, "utf8").trim().split("\n");
|
|
assert.ok(lines.some((l) => l.endsWith("php occ user:add --password-from-env --display-name=Mesh administration mesh-admin")), lines.join("\n"));
|
|
assert.ok(!lines.some((l) => l.includes("bruteforce")), "the brute-force record is left alone");
|
|
assert.ok(!lines.join("\n").includes(PASSWORD));
|
|
});
|