fix(auth): reject ill-formed local passwords
This commit is contained in:
@@ -53,6 +53,12 @@ function parsePHC(encoded: string): Argon2Parameters | undefined {
|
||||
|
||||
function passwordBytes(password: string): Buffer | undefined {
|
||||
if (typeof password !== "string") return undefined;
|
||||
const typedPassword = password as string & { isWellFormed?: () => boolean };
|
||||
if (typeof typedPassword.isWellFormed === "function") {
|
||||
if (!typedPassword.isWellFormed()) return undefined;
|
||||
} else if (/[\uD800-\uDFFF]/.test(password)) {
|
||||
return undefined;
|
||||
}
|
||||
const bytes = Buffer.from(password, "utf8");
|
||||
return bytes.length >= MINIMUM_PASSWORD_BYTES && bytes.length <= MAXIMUM_PASSWORD_BYTES ? bytes : undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { argon2SyncSpy } = vi.hoisted(() => ({ argon2SyncSpy: vi.fn() }));
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
argon2SyncSpy.mockImplementation(actual.argon2Sync);
|
||||
return { ...actual, argon2Sync: argon2SyncSpy };
|
||||
});
|
||||
|
||||
import { verifyPassword } from "../src/auth/password.js";
|
||||
|
||||
interface Argon2Vector {
|
||||
@@ -27,6 +35,15 @@ describe("local Argon2id password verification", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects ill-formed Unicode instead of authenticating as U+FFFD", () => {
|
||||
const replacementPassword = "correct horse battery stap\uFFFD";
|
||||
const loneSurrogatePassword = "correct horse battery stap\uD800";
|
||||
const replacementPasswordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$+tAXzgaQVnNaonNvgevyG6UKlaKcwyRMi1mESNk0BvQ";
|
||||
|
||||
expect(verifyPassword(replacementPassword, replacementPasswordHash)).toBe(true);
|
||||
expect(verifyPassword(loneSurrogatePassword, replacementPasswordHash)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects malformed and oversized PHC parameters before Argon2 allocation", () => {
|
||||
const password = vectors[0].password;
|
||||
const digest = vectors[0].phc.split("$")[5];
|
||||
@@ -40,8 +57,9 @@ describe("local Argon2id password verification", () => {
|
||||
];
|
||||
|
||||
for (const phc of cases) {
|
||||
expect(() => verifyPassword(password, phc)).not.toThrow();
|
||||
argon2SyncSpy.mockClear();
|
||||
expect(verifyPassword(password, phc)).toBe(false);
|
||||
expect(argon2SyncSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
realpathSync,
|
||||
symlinkSync,
|
||||
@@ -128,6 +127,10 @@ describe("local user registry", () => {
|
||||
["duplicate normalized usernames", registryYaml(userYaml() + userYaml({ id: userId, username: "admin" }))],
|
||||
["duplicate IDs", registryYaml(userYaml() + userYaml({ username: "operator" }))],
|
||||
["unknown YAML fields", `${registryYaml(userYaml())}unexpected: true\n`],
|
||||
["no enabled admin", registryYaml(userYaml({ role: "user" }))],
|
||||
["duplicate roles", registryYaml(userYaml().replace(" - admin", " - admin\n - admin"))],
|
||||
["invalid password hash", registryYaml(userYaml().replace(passwordHash, "not-a-password-hash"))],
|
||||
["control character in display name", registryYaml(userYaml().replace("displayName: Admin", 'displayName: "Admin\\tUser"'))],
|
||||
])("rejects %s", async (_name, contents) => {
|
||||
const fixture = writeRegistry(contents);
|
||||
await expectInvalid(createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path]);
|
||||
|
||||
Reference in New Issue
Block a user