nextcloud: repair only the module's own account, read its existence from the user list, no admin group (issue 1253, review 1)
occ's user:info says 'user not found' on stdout with an empty stderr, so the error read as 'occ produced no output' and mesh-admin was never made on novox: occ now says stderr and stdout both, and existence is read from user:list. The repair could reset whatever account the user setting names, the operator's included, and the 'admin' fallback is a hand-made install's human administrator: only mesh-admin is repaired now, and it is the default. The shares poll needs no admin rights, so the account gets no group. After a repair the throttle of the caller's address is reset, and a failing repair reaches the back-off's one loud line with occ's message.
This commit is contained in:
@@ -15,8 +15,11 @@ 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(): any {
|
||||
return new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", "mesh-admin", PASSWORD);
|
||||
function client(user = "mesh-admin"): any {
|
||||
const c = new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", user, PASSWORD);
|
||||
// The address Nextcloud sees the module's calls arrive from (docker inspect in the real one).
|
||||
mock.method(c, "callerAddresses", () => ["172.17.0.1"]);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** A stand-in Nextcloud: answers `statuses` in turn (the last one repeating) and counts the asks. */
|
||||
@@ -32,25 +35,31 @@ function standInFetch(statuses: number[]) {
|
||||
}
|
||||
|
||||
/** A stand-in occ: knows whether the account exists, records every call and every env passed. */
|
||||
function standInOcc(accountExists: boolean) {
|
||||
function standInOcc(accountExists: boolean, opts: { failWrite?: string } = {}) {
|
||||
const calls: string[][] = [];
|
||||
const envs: Array<Record<string, string> | undefined> = [];
|
||||
const impl = (args: string[], opts: { env?: Record<string, string> } = {}): string => {
|
||||
const impl = (args: string[], occOpts: { env?: Record<string, string> } = {}): string => {
|
||||
calls.push(args);
|
||||
envs.push(opts.env);
|
||||
if (args[0] === "user:info") {
|
||||
if (!accountExists) throw new Error("user not found");
|
||||
return "{}";
|
||||
envs.push(occOpts.env);
|
||||
if (args[0] === "user:list") {
|
||||
return JSON.stringify(accountExists ? { jochens: "Jochen", "mesh-admin": "Mesh administration" } : { jochens: "Jochen" });
|
||||
}
|
||||
if (["user:add", "user:resetpassword", "group:adduser"].includes(args[0])) return "";
|
||||
if (["user:add", "user:resetpassword"].includes(args[0])) {
|
||||
if (opts.failWrite) throw new Error(opts.failWrite);
|
||||
return "";
|
||||
}
|
||||
if (args[0] === "security:bruteforce:reset") return "";
|
||||
throw new Error("unexpected occ call: " + args.join(" "));
|
||||
};
|
||||
return { calls, envs, impl };
|
||||
}
|
||||
|
||||
/** The occ calls that write: anything but a read of the user list. */
|
||||
const writes = (calls: string[][]) => calls.filter((a) => a[0] !== "user:list");
|
||||
|
||||
// 1253/AC1: given an adopted instance without the mesh's account, when Nextcloud refuses the
|
||||
// login, then the account is added in the admin group with the module's own secret, by
|
||||
// OC_PASS and never on an argv, and the shares are asked again and listed.
|
||||
// login, then the account is added (no admin group) with the module's own secret, by OC_PASS and
|
||||
// never on an argv, the throttle of the caller's address is reset, and the shares are listed.
|
||||
test("a refused login adds the missing account, password by environment, and lists the shares", async () => {
|
||||
const c = client();
|
||||
const occ = standInOcc(false);
|
||||
@@ -63,7 +72,9 @@ test("a refused login adds the missing account, password by environment, and lis
|
||||
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("--group=admin") && add.includes("mesh-admin"));
|
||||
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.at(-1), ["security:bruteforce:reset", "172.17.0.1"]);
|
||||
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 {
|
||||
@@ -72,15 +83,15 @@ test("a refused login adds the missing account, password by environment, and lis
|
||||
});
|
||||
|
||||
// 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 and it is put in the admin group.
|
||||
test("a refused login resets an existing account's password and puts it in the admin group", async () => {
|
||||
// then its password is set to the module's own secret, in no group, and the throttle is reset.
|
||||
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", "group:adduser"]);
|
||||
assert.deepEqual(occ.calls.map((a) => a[0]), ["user:list", "user:resetpassword", "security:bruteforce:reset"]);
|
||||
assert.equal(occ.envs[1]?.OC_PASS, PASSWORD);
|
||||
assert.ok(!occ.calls.flat().some((a) => a.includes(PASSWORD)));
|
||||
} finally {
|
||||
@@ -205,3 +216,78 @@ test("an empty user setting falls back to the manifest's account", () => {
|
||||
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(users: Record<string, string>): { 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 ;;",
|
||||
` *' occ user:list '*) printf '%s' '${JSON.stringify(users)}' ;;`,
|
||||
" inspect*) echo '172.17.0.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({ jochens: "Jochen" });
|
||||
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.deepEqual(did, { account: "created", throttleReset: true });
|
||||
});
|
||||
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.endsWith("php occ security:bruteforce:reset 172.17.0.1")), lines.join("\n"));
|
||||
assert.ok(!lines.join("\n").includes(PASSWORD));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user