fix(auth): protect local registry directory
This commit is contained in:
@@ -44,6 +44,17 @@ interface FileIdentity {
|
|||||||
mtimeMs: number;
|
mtimeMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DirectoryIdentity {
|
||||||
|
dev: number;
|
||||||
|
ino: number;
|
||||||
|
mode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegistryIdentity {
|
||||||
|
file: FileIdentity;
|
||||||
|
directory: DirectoryIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
const roleSchema = z.enum(ROLES);
|
const roleSchema = z.enum(ROLES);
|
||||||
const userSchema = z.strictObject({
|
const userSchema = z.strictObject({
|
||||||
id: z.string().regex(UUID_V4_PATTERN),
|
id: z.string().regex(UUID_V4_PATTERN),
|
||||||
@@ -62,10 +73,18 @@ function normalizeUsername(username: string): string {
|
|||||||
return username.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
return username.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
||||||
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
|
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean {
|
||||||
|
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameIdentity(left: RegistryIdentity, right: RegistryIdentity): boolean {
|
||||||
|
return sameFileIdentity(left.file, right.file) && sameDirectoryIdentity(left.directory, right.directory);
|
||||||
|
}
|
||||||
|
|
||||||
function validateCanonicalPath(path: string): void {
|
function validateCanonicalPath(path: string): void {
|
||||||
if (typeof path !== "string" || path.length === 0 || path.includes("\0") || !isAbsolute(path) || normalize(path) !== path) throw invalid();
|
if (typeof path !== "string" || path.length === 0 || path.includes("\0") || !isAbsolute(path) || normalize(path) !== path) throw invalid();
|
||||||
const parent = dirname(path);
|
const parent = dirname(path);
|
||||||
@@ -76,27 +95,59 @@ function validateMetadata(info: { isFile(): boolean; nlink: number; mode: number
|
|||||||
if (!info.isFile() || info.nlink !== 1 || (info.mode & 0o7777) !== 0o600) throw invalid();
|
if (!info.isFile() || info.nlink !== 1 || (info.mode & 0o7777) !== 0o600) throw invalid();
|
||||||
}
|
}
|
||||||
|
|
||||||
function metadata(info: Stats): FileIdentity {
|
function fileMetadata(info: Stats): FileIdentity {
|
||||||
validateMetadata(info);
|
validateMetadata(info);
|
||||||
if (info.size < 0 || info.size > MAX_USERS_YAML_BYTES) throw invalid();
|
if (info.size < 0 || info.size > MAX_USERS_YAML_BYTES) throw invalid();
|
||||||
return { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs };
|
return { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs };
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileIdentity(path: string): FileIdentity {
|
function directoryMetadata(info: Stats): DirectoryIdentity {
|
||||||
validateCanonicalPath(path);
|
if (!info.isDirectory()) throw invalid();
|
||||||
const info = lstatSync(path);
|
if (process.platform !== "win32" && (info.mode & 0o7777) !== 0o700) throw invalid();
|
||||||
return metadata(info as Stats);
|
return {
|
||||||
|
dev: info.dev,
|
||||||
|
ino: info.ino,
|
||||||
|
...(process.platform === "win32" ? {} : { mode: info.mode & 0o7777 }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBounded(path: string): { source: string; identity: FileIdentity } {
|
function directoryIdentity(path: string): DirectoryIdentity {
|
||||||
|
const parent = dirname(path);
|
||||||
|
if (realpathSync(parent) !== parent) throw invalid();
|
||||||
|
return directoryMetadata(lstatSync(parent) as Stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registryIdentity(path: string): RegistryIdentity {
|
||||||
validateCanonicalPath(path);
|
validateCanonicalPath(path);
|
||||||
|
const info = lstatSync(path);
|
||||||
|
return { file: fileMetadata(info as Stats), directory: directoryIdentity(path) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDirectoryDescriptor(path: string): number | undefined {
|
||||||
|
if (process.platform === "win32") return undefined;
|
||||||
|
const flags = constants.O_RDONLY
|
||||||
|
| (constants.O_DIRECTORY ?? 0)
|
||||||
|
| (constants.O_NOFOLLOW ?? 0)
|
||||||
|
| (constants.O_NONBLOCK ?? 0);
|
||||||
|
return openSync(path, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBounded(path: string): { source: string; identity: RegistryIdentity } {
|
||||||
|
validateCanonicalPath(path);
|
||||||
|
const beforeDirectory = directoryIdentity(path);
|
||||||
const beforePath = lstatSync(path);
|
const beforePath = lstatSync(path);
|
||||||
const before = metadata(beforePath);
|
const before = fileMetadata(beforePath as Stats);
|
||||||
|
let directoryDescriptor: number | undefined;
|
||||||
let descriptor: number | undefined;
|
let descriptor: number | undefined;
|
||||||
try {
|
try {
|
||||||
|
directoryDescriptor = openDirectoryDescriptor(dirname(path));
|
||||||
|
const openedDirectory = directoryDescriptor === undefined
|
||||||
|
? beforeDirectory
|
||||||
|
: directoryMetadata(fstatSync(directoryDescriptor) as Stats);
|
||||||
|
if (!sameDirectoryIdentity(beforeDirectory, openedDirectory)) throw invalid();
|
||||||
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||||
const opened = metadata(fstatSync(descriptor) as Stats);
|
const opened = fileMetadata(fstatSync(descriptor) as Stats);
|
||||||
if (!sameIdentity(before, opened)) throw invalid();
|
if (!sameFileIdentity(before, opened)) throw invalid();
|
||||||
const buffer = Buffer.allocUnsafe(MAX_USERS_YAML_BYTES + 1);
|
const buffer = Buffer.allocUnsafe(MAX_USERS_YAML_BYTES + 1);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
while (offset < buffer.length) {
|
while (offset < buffer.length) {
|
||||||
@@ -105,17 +156,26 @@ function readBounded(path: string): { source: string; identity: FileIdentity } {
|
|||||||
offset += bytesRead;
|
offset += bytesRead;
|
||||||
}
|
}
|
||||||
if (offset > MAX_USERS_YAML_BYTES) throw invalid();
|
if (offset > MAX_USERS_YAML_BYTES) throw invalid();
|
||||||
const after = metadata(fstatSync(descriptor) as Stats);
|
const after = fileMetadata(fstatSync(descriptor) as Stats);
|
||||||
const afterPath = metadata(lstatSync(path) as Stats);
|
const afterPath = fileMetadata(lstatSync(path) as Stats);
|
||||||
if (!sameIdentity(opened, after) || !sameIdentity(after, afterPath)) throw invalid();
|
const afterDirectory = directoryMetadata(lstatSync(dirname(path)) as Stats);
|
||||||
|
const afterOpenedDirectory = directoryDescriptor === undefined
|
||||||
|
? afterDirectory
|
||||||
|
: directoryMetadata(fstatSync(directoryDescriptor) as Stats);
|
||||||
|
if (!sameFileIdentity(opened, after) || !sameFileIdentity(after, afterPath)
|
||||||
|
|| !sameDirectoryIdentity(beforeDirectory, afterDirectory)
|
||||||
|
|| !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid();
|
||||||
const source = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
|
const source = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
|
||||||
return { source, identity: after };
|
return { source, identity: { file: after, directory: afterDirectory } };
|
||||||
} catch {
|
} catch {
|
||||||
throw invalid();
|
throw invalid();
|
||||||
} finally {
|
} finally {
|
||||||
if (descriptor !== undefined) {
|
if (descriptor !== undefined) {
|
||||||
try { closeSync(descriptor); } catch { /* sanitized by design */ }
|
try { closeSync(descriptor); } catch { /* sanitized by design */ }
|
||||||
}
|
}
|
||||||
|
if (directoryDescriptor !== undefined) {
|
||||||
|
try { closeSync(directoryDescriptor); } catch { /* sanitized by design */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,21 +211,21 @@ function parseRegistry(source: string): LocalUserRecord[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(path: string): { records: LocalUserRecord[]; identity: FileIdentity } {
|
function load(path: string): { records: LocalUserRecord[]; identity: RegistryIdentity } {
|
||||||
const read = readBounded(path);
|
const read = readBounded(path);
|
||||||
return { records: parseRegistry(read.source), identity: read.identity };
|
return { records: parseRegistry(read.source), identity: read.identity };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLocalUserRegistry(usersPath: string): LocalUserRegistry {
|
export function createLocalUserRegistry(usersPath: string): LocalUserRegistry {
|
||||||
let cached: { records: LocalUserRecord[]; identity: FileIdentity } | undefined;
|
let cached: { records: LocalUserRecord[]; identity: RegistryIdentity } | undefined;
|
||||||
|
|
||||||
function current(): LocalUserRecord[] {
|
function current(): LocalUserRecord[] {
|
||||||
try {
|
try {
|
||||||
const before = fileIdentity(usersPath);
|
const before = registryIdentity(usersPath);
|
||||||
if (cached && sameIdentity(cached.identity, before)) return cached.records;
|
if (cached && sameIdentity(cached.identity, before)) return cached.records;
|
||||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
const loaded = load(usersPath);
|
const loaded = load(usersPath);
|
||||||
if (sameIdentity(loaded.identity, fileIdentity(usersPath))) {
|
if (sameIdentity(loaded.identity, registryIdentity(usersPath))) {
|
||||||
cached = loaded;
|
cached = loaded;
|
||||||
return loaded.records;
|
return loaded.records;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
chmodSync,
|
chmodSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
lstatSync,
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
renameSync,
|
renameSync,
|
||||||
realpathSync,
|
realpathSync,
|
||||||
symlinkSync,
|
symlinkSync,
|
||||||
@@ -44,6 +45,7 @@ function lstatMaybe(path: string): boolean {
|
|||||||
function root(): string {
|
function root(): string {
|
||||||
const path = mkdtempSync(join(realpathSync(tmpdir()), "thothii-local-registry-"));
|
const path = mkdtempSync(join(realpathSync(tmpdir()), "thothii-local-registry-"));
|
||||||
chmodSync(path, 0o700);
|
chmodSync(path, 0o700);
|
||||||
|
if ((lstatSync(path).mode & 0o7777) !== 0o700) throw new Error("test root is not private");
|
||||||
createdRoots.push(path);
|
createdRoots.push(path);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
@@ -123,6 +125,28 @@ describe("local user registry", () => {
|
|||||||
await expect(registry.verify(await registry.findByUsername("operator"), password)).resolves.toBe(false);
|
await expect(registry.verify(await registry.findByUsername("operator"), password)).resolves.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects a valid registry under a non-private authentication directory", async () => {
|
||||||
|
const fixture = writeRegistry(registryYaml(userYaml()));
|
||||||
|
chmodSync(fixture.root, 0o750);
|
||||||
|
expect(lstatSync(fixture.root).mode & 0o7777).toBe(0o750);
|
||||||
|
|
||||||
|
await expectInvalid(createLocalUserRegistry(fixture.path).findByUsername("admin"), ["admin", passwordHash, fixture.path]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a valid registry under a symlinked authentication directory", async () => {
|
||||||
|
const outer = root();
|
||||||
|
const realDirectory = join(outer, "real-auth");
|
||||||
|
const linkedDirectory = join(outer, "linked-auth");
|
||||||
|
mkdirSync(realDirectory, { mode: 0o700 });
|
||||||
|
chmodSync(realDirectory, 0o700);
|
||||||
|
const path = join(realDirectory, "users.yaml");
|
||||||
|
writeFileSync(path, registryYaml(userYaml()), { encoding: "utf8", mode: 0o600 });
|
||||||
|
chmodSync(path, 0o600);
|
||||||
|
symlinkSync(realDirectory, linkedDirectory);
|
||||||
|
|
||||||
|
await expectInvalid(createLocalUserRegistry(join(linkedDirectory, "users.yaml")).findByUsername("admin"), ["admin", passwordHash]);
|
||||||
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
["duplicate normalized usernames", registryYaml(userYaml() + userYaml({ id: userId, username: "admin" }))],
|
["duplicate normalized usernames", registryYaml(userYaml() + userYaml({ id: userId, username: "admin" }))],
|
||||||
["duplicate IDs", registryYaml(userYaml() + userYaml({ username: "operator" }))],
|
["duplicate IDs", registryYaml(userYaml() + userYaml({ username: "operator" }))],
|
||||||
|
|||||||
Reference in New Issue
Block a user