nextcloud: repair the account only on a 401, never reset the throttle (issue 1253, review 2)
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…
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.
This commit is contained in:
+30
-37
@@ -185,7 +185,9 @@ export class NextcloudClient {
|
||||
// occ writes many refusals ("user not found") to stdout with an empty stderr: say both.
|
||||
const detail = [err?.stderr, err?.stdout].map((x) => String(x ?? "").trim()).filter(Boolean).join("\n")
|
||||
|| String(err?.message ?? "").trim();
|
||||
throw new Error(detail || `occ produced no output — is the ${this.container} container running?`);
|
||||
// The exit status rides along, so a caller can tell occ's own answer from a failure.
|
||||
throw Object.assign(new Error(detail || `occ produced no output — is the ${this.container} container running?`),
|
||||
{ status: typeof err?.status === "number" ? err.status : undefined });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,49 +267,40 @@ export class NextcloudClient {
|
||||
* The image creates NEXTCLOUD_ADMIN_USER only when it installs a fresh instance, so an instance
|
||||
* the mesh adopted never has it, and a secret made anew never reaches it. Only the module's own
|
||||
* account (MESH_ACCOUNT) is touched: under any other name it writes nothing and throws
|
||||
* LoginRefusedError. Through occ: whether the account exists is read from user:list's
|
||||
* membership; a missing one is added, an existing one gets its password set to the secret. It
|
||||
* gets no admin group: the shares poll needs no admin rights (least privilege). The password
|
||||
* goes by OC_PASS (`--password-from-env`), never on an argv. Then the brute-force record of the
|
||||
* addresses the module's calls arrive from is reset, so the first good login is not held behind
|
||||
* the throttle. A failing occ write is thrown as LoginRefusedError carrying occ's own message.
|
||||
* LoginRefusedError. Through occ: whether the account exists is read from `user:info`, whose
|
||||
* exit status 1 with "not found" means missing (user:list pages, so it is not asked); a missing
|
||||
* one is added, an existing one gets its password set to the secret. It gets no admin group: the
|
||||
* shares poll needs no admin rights (least privilege). The password goes by OC_PASS
|
||||
* (`--password-from-env`), never on an argv. Nextcloud's brute-force record is left alone: it is
|
||||
* shared by every client behind the same address, and the caller's back-off outlasts it. A
|
||||
* failing occ is thrown as LoginRefusedError carrying occ's own message.
|
||||
*/
|
||||
ensureAdminAccount(): { account: "created" | "password-set"; throttleReset: boolean } {
|
||||
ensureAdminAccount(): "created" | "password-set" {
|
||||
if (this.adminUser !== MESH_ACCOUNT) {
|
||||
throw new LoginRefusedError(401, this.adminUser, `not repaired: only ${MESH_ACCOUNT}, the module's own account, is`);
|
||||
}
|
||||
const pass = { env: { OC_PASS: this.adminPassword } };
|
||||
let account: "created" | "password-set";
|
||||
try {
|
||||
if (!this.listUsers().some((u) => u.uid === this.adminUser)) {
|
||||
if (!this.accountExists()) {
|
||||
this.occ(["user:add", "--password-from-env", "--display-name=Mesh administration", this.adminUser], pass);
|
||||
account = "created";
|
||||
} else {
|
||||
this.occ(["user:resetpassword", "--password-from-env", this.adminUser], pass);
|
||||
account = "password-set";
|
||||
return "created";
|
||||
}
|
||||
this.occ(["user:resetpassword", "--password-from-env", this.adminUser], pass);
|
||||
return "password-set";
|
||||
} catch (err) {
|
||||
throw new LoginRefusedError(401, this.adminUser, `repair failed: ${String((err as Error)?.message ?? err).split("\n")[0]}`);
|
||||
}
|
||||
let throttleReset = true;
|
||||
try {
|
||||
for (const address of this.callerAddresses()) this.occ(["security:bruteforce:reset", address]);
|
||||
} catch {
|
||||
throttleReset = false;
|
||||
}
|
||||
return { account, throttleReset };
|
||||
}
|
||||
|
||||
/**
|
||||
* The addresses the module's calls reach Nextcloud from, as Nextcloud sees them: the gateway of
|
||||
* each network the container is on (a published port arrives from it; the access log shows
|
||||
* 172.17.0.1 on the default bridge).
|
||||
*/
|
||||
callerAddresses(): string[] {
|
||||
const out = execFileSync("docker", ["inspect", "-f", "{{range .NetworkSettings.Networks}}{{.Gateway}} {{end}}", this.container], {
|
||||
encoding: "utf8", timeout: 30_000,
|
||||
});
|
||||
return out.split(/\s+/).filter(Boolean);
|
||||
/** Whether the account exists: occ user:info answers it, exit 1 and "not found" when missing. */
|
||||
private accountExists(): boolean {
|
||||
try {
|
||||
this.occ(["user:info", this.adminUser, "--output=json"]);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err?.status === 1 && /not found/i.test(String(err?.message ?? ""))) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private fetchShares(): Promise<Response> {
|
||||
@@ -319,15 +312,15 @@ export class NextcloudClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* List every share, over the OCS Sharing API as the mesh's own account. A refused login (401) or
|
||||
* a throttle (429) makes the account right once (ensureAdminAccount) and asks again; a refusal
|
||||
* or a throttle after that throws LoginRefusedError, so the caller backs off (novox/hq issue 1253).
|
||||
* List every share, over the OCS Sharing API as the mesh's own account. A refused login (401)
|
||||
* makes the account right once (ensureAdminAccount) and asks again; a refusal after that, or
|
||||
* any throttle (429), throws LoginRefusedError, so the caller backs off (novox/hq issue 1253).
|
||||
*/
|
||||
async listShares(): Promise<NextcloudShare[]> {
|
||||
let res = await this.fetchShares();
|
||||
// A throttle (429) follows refusals, so it gets the same one repair: the repair goes by occ,
|
||||
// never by a login, and the throttle then wears off under the caller's back-off.
|
||||
if ((res.status === 401 || res.status === 429) && !this.accountMadeRight) {
|
||||
// Only a refused login (401) is repaired. A throttle (429) is no evidence the password is
|
||||
// wrong (anyone guessing passwords from the same address causes it): it is backed off only.
|
||||
if (res.status === 401 && !this.accountMadeRight) {
|
||||
await res.text();
|
||||
this.accountMadeRight = true;
|
||||
this.ensureAdminAccount();
|
||||
|
||||
Reference in New Issue
Block a user