fix(auth): tighten bridge claim protocol
This commit is contained in:
@@ -609,6 +609,9 @@ function claimOidcState(directory: string, filename: string): OidcStateClaim | u
|
||||
before = fileIdentity(lstatSync(statePath) as Stats);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return undefined;
|
||||
// A winner can install its hard-link after the first claim check and before this state
|
||||
// identity check. That process owns the state; this contender must fail closed as used.
|
||||
if (oidcClaimExists(directory, filename)) return undefined;
|
||||
throw invalid();
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface WindowsAuthStorageBridgeOptions {
|
||||
thtExecutable?: string;
|
||||
/** Test-only child-launch seam; production uses the fixed no-shell Node child-process launcher. */
|
||||
spawnChild?: WindowsAuthStorageSpawn;
|
||||
/** Test-only input scheduling seam for real child-process lifecycle tests. */
|
||||
beforeInputForTest?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const responseSchema = z.strictObject({
|
||||
@@ -117,8 +119,8 @@ function validateRoot(root: string): void {
|
||||
|| !win32.isAbsolute(root) || win32.normalize(root) !== root) throw invalid();
|
||||
}
|
||||
|
||||
function validateFilename(filename: string, claim = false): void {
|
||||
if (typeof filename !== "string" || !(claim ? CLAIM_FILENAME : DIGEST_FILENAME).test(filename)) throw invalid();
|
||||
function validateFilename(filename: string, allowClaim = false): void {
|
||||
if (typeof filename !== "string" || (!DIGEST_FILENAME.test(filename) && !(allowClaim && CLAIM_FILENAME.test(filename)))) throw invalid();
|
||||
}
|
||||
|
||||
function safeThtExecutable(value: string | undefined): string {
|
||||
@@ -144,7 +146,16 @@ function parseResponse(result: WindowsAuthStorageInvocationResult): BridgeRespon
|
||||
|
||||
function encodedRequest(request: BridgeRequest): Buffer {
|
||||
validateRoot(request.root);
|
||||
if (request.filename !== undefined) validateFilename(request.filename);
|
||||
if (request.operation === "list") {
|
||||
if (request.filename !== undefined || request.contentBase64 !== undefined) throw invalid();
|
||||
} else {
|
||||
if (request.filename === undefined) throw invalid();
|
||||
const allowClaim = request.operation === "remove" && request.directory === "oidc";
|
||||
validateFilename(request.filename, allowClaim);
|
||||
if (request.contentBase64 !== undefined && request.operation !== "create" && request.operation !== "replace") throw invalid();
|
||||
}
|
||||
if ((request.operation === "claim-consume" || request.operation === "read-claim" || request.operation === "remove-claim")
|
||||
&& request.directory !== "oidc") throw invalid();
|
||||
if (request.contentBase64 !== undefined) canonicalBase64(request.contentBase64, directoryMaximum(request.directory));
|
||||
const encoded = Buffer.from(JSON.stringify(request), "utf8");
|
||||
if (encoded.length === 0 || encoded.length > MAX_PROTOCOL_BYTES) throw invalid();
|
||||
@@ -165,6 +176,7 @@ const spawnTht: WindowsAuthStorageSpawn = (executable, args, options) => spawn(e
|
||||
async function invokeTht(
|
||||
invocation: WindowsAuthStorageInvocation,
|
||||
spawnChild: WindowsAuthStorageSpawn = spawnTht,
|
||||
beforeInputForTest?: () => Promise<void>,
|
||||
): Promise<WindowsAuthStorageInvocationResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
@@ -260,10 +272,18 @@ async function invokeTht(
|
||||
stderr.push(Buffer.from(chunk));
|
||||
});
|
||||
stdin.once("error", abort);
|
||||
try {
|
||||
stdin.end(invocation.input);
|
||||
} catch {
|
||||
abort();
|
||||
const writeInput = (): void => {
|
||||
if (aborted || settled) return;
|
||||
try {
|
||||
stdin.end(invocation.input);
|
||||
} catch {
|
||||
abort();
|
||||
}
|
||||
};
|
||||
if (beforeInputForTest === undefined) {
|
||||
writeInput();
|
||||
} else {
|
||||
void Promise.resolve().then(beforeInputForTest).then(writeInput, abort);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -279,7 +299,11 @@ function contentFrom(response: BridgeResponse, maximum: number): Buffer | undefi
|
||||
|
||||
export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge {
|
||||
const executable = safeThtExecutable(options.thtExecutable);
|
||||
const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht(invocation, options.spawnChild));
|
||||
const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht(
|
||||
invocation,
|
||||
options.spawnChild,
|
||||
options.beforeInputForTest,
|
||||
));
|
||||
const request = async (value: BridgeRequest): Promise<BridgeResponse> => {
|
||||
try {
|
||||
const response = await invoke({
|
||||
@@ -325,7 +349,7 @@ export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridge
|
||||
const response = await request({ version: PROTOCOL_VERSION, operation: "list", root, directory });
|
||||
if (response.entries === undefined) throw invalid();
|
||||
for (const entry of response.entries) {
|
||||
if (!DIGEST_FILENAME.test(entry.name) && !CLAIM_FILENAME.test(entry.name)) throw invalid();
|
||||
if (!DIGEST_FILENAME.test(entry.name) && !(directory === "oidc" && CLAIM_FILENAME.test(entry.name))) throw invalid();
|
||||
}
|
||||
return response.entries.map((entry) => ({ name: entry.name, modifiedUnixMs: entry.modifiedUnixMs }));
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
const fsHooks = vi.hoisted(() => ({
|
||||
afterRead: undefined as undefined | (() => void),
|
||||
afterWrite: undefined as undefined | (() => void),
|
||||
beforeLstat: undefined as undefined | ((path: string) => boolean),
|
||||
afterLstat: undefined as undefined | ((path: string) => boolean),
|
||||
transformLstat: undefined as undefined | ((path: string, info: import("node:fs").Stats) => import("node:fs").Stats),
|
||||
}));
|
||||
@@ -45,6 +46,8 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
return result;
|
||||
},
|
||||
lstatSync: (...args: Parameters<typeof actual.lstatSync>) => {
|
||||
const before = fsHooks.beforeLstat;
|
||||
if (before?.(String(args[0]))) fsHooks.beforeLstat = undefined;
|
||||
const original = actual.lstatSync(...args);
|
||||
const result = fsHooks.transformLstat?.(String(args[0]), original) ?? original;
|
||||
const callback = fsHooks.afterLstat;
|
||||
@@ -69,6 +72,7 @@ const validLocalUser = { enabled: true, authRevision: 7, roles: ["admin"] as con
|
||||
afterEach(() => {
|
||||
fsHooks.afterRead = undefined;
|
||||
fsHooks.afterWrite = undefined;
|
||||
fsHooks.beforeLstat = undefined;
|
||||
fsHooks.afterLstat = undefined;
|
||||
fsHooks.transformLstat = undefined;
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
@@ -297,6 +301,23 @@ describe("file-backed auth session store", () => {
|
||||
expect(existsSync(statePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("treats a competing OIDC claim installed between availability and state checks as unavailable", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
const created = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
|
||||
const statePath = digestPath(storageRoot, "oidc", created.state);
|
||||
const stateClaimPath = claimPath(storageRoot, created.state);
|
||||
fsHooks.beforeLstat = (observed) => {
|
||||
if (observed !== statePath) return false;
|
||||
linkSync(statePath, stateClaimPath);
|
||||
return true;
|
||||
};
|
||||
|
||||
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
|
||||
expect(existsSync(statePath)).toBe(true);
|
||||
expect(existsSync(stateClaimPath)).toBe(true);
|
||||
});
|
||||
|
||||
test("prunes an expired OIDC state abandoned after an atomic claim", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
@@ -639,6 +660,72 @@ describe("file-backed auth session store", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("prunes empty Windows directories through the required Go entries array", async () => {
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\tht.exe",
|
||||
invoke: async () => ({
|
||||
code: 0,
|
||||
stdout: Buffer.from('{"version":1,"ok":true,"entries":[]}\n'),
|
||||
stderr: Buffer.alloc(0),
|
||||
}),
|
||||
});
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
if (!originalPlatform) throw new Error("platform descriptor unavailable");
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
|
||||
try {
|
||||
const store = createFileAuthSessionStore("C:\\ProgramData\\ThothII\\auth", {
|
||||
currentAuthConfigRevision: () => revision,
|
||||
findLocalUser: async () => validLocalUser,
|
||||
}, { windowsStorageBridge: bridge });
|
||||
|
||||
await expect(store.prune(base)).resolves.toBe(0);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
});
|
||||
|
||||
test("prunes a stale Windows OIDC orphan claim through the Go DTO path", async () => {
|
||||
const claim = `${"d".repeat(64)}.claim`;
|
||||
let removed = false;
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\tht.exe",
|
||||
invoke: async ({ input }) => {
|
||||
const request = JSON.parse(input.toString("utf8")) as { operation: string; directory: string; filename?: string };
|
||||
const response = (value: Record<string, unknown>) => ({
|
||||
code: 0,
|
||||
stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, ...value })}\n`),
|
||||
stderr: Buffer.alloc(0),
|
||||
});
|
||||
if (request.operation === "list") {
|
||||
return response({
|
||||
entries: request.directory === "oidc"
|
||||
? [{ name: claim, modifiedUnixMs: base.getTime() }]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
if (request.operation === "remove" && request.directory === "oidc" && request.filename === claim) {
|
||||
removed = true;
|
||||
return response({ removed: true });
|
||||
}
|
||||
throw new Error("unexpected bridge request");
|
||||
},
|
||||
});
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
if (!originalPlatform) throw new Error("platform descriptor unavailable");
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
|
||||
try {
|
||||
const store = createFileAuthSessionStore("C:\\ProgramData\\ThothII\\auth", {
|
||||
currentAuthConfigRevision: () => revision,
|
||||
findLocalUser: async () => validLocalUser,
|
||||
}, { windowsStorageBridge: bridge });
|
||||
|
||||
await expect(store.prune(new Date(base.getTime() + 11 * 60_000))).resolves.toBe(1);
|
||||
expect(removed).toBe(true);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
});
|
||||
|
||||
test("revokes on config, local-user, revision, enabled, or role mismatch before returning", async () => {
|
||||
const storageRoot = root();
|
||||
let currentRevision = revision;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { appendFileSync, closeSync, writeFileSync } from "node:fs";
|
||||
|
||||
const [, , mode, marker] = process.argv;
|
||||
|
||||
writeFileSync(marker, `started:${process.pid}\n`);
|
||||
process.on("exit", () => appendFileSync(marker, "exited\n"));
|
||||
process.on("SIGTERM", () => {
|
||||
appendFileSync(marker, "terminated\n");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
if (mode === "stdin") {
|
||||
closeSync(0);
|
||||
appendFileSync(marker, "stdin-closed\n");
|
||||
} else if (mode === "stdout") {
|
||||
process.stdout.write(Buffer.alloc(64 * 1024 + 1));
|
||||
} else if (mode === "stderr") {
|
||||
process.stderr.write(Buffer.alloc(64 * 1024 + 1));
|
||||
}
|
||||
|
||||
setInterval(() => {}, 1_000);
|
||||
@@ -1,10 +1,63 @@
|
||||
import { appendFileSync, chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { createWindowsAuthStorageBridge } from "../src/auth/windows-auth-storage.js";
|
||||
|
||||
const root = "C:\\ProgramData\\ThothII\\auth";
|
||||
const filename = "a".repeat(64) + ".json";
|
||||
const realChildFixture = fileURLToPath(new URL("./fixtures/windows-auth-storage-real-child.mjs", import.meta.url));
|
||||
const fixtureRoots: string[] = [];
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
async function waitForMarker(marker: string, expected: string): Promise<void> {
|
||||
const deadline = Date.now() + 3_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(marker) && readFileSync(marker, "utf8").includes(expected)) return;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`real helper marker did not contain ${expected}`);
|
||||
}
|
||||
|
||||
function realChildBridge(mode: "timeout" | "stdout" | "stderr" | "stdin") {
|
||||
const directory = mkdtempSync(join(tmpdir(), "thothii-auth-bridge-child-"));
|
||||
fixtureRoots.push(directory);
|
||||
const marker = join(directory, "marker.txt");
|
||||
const launcher = join(directory, "tht.exe");
|
||||
writeFileSync(launcher, `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(realChildFixture)} ${shellQuote(mode)} ${shellQuote(marker)} "$@"\n`, { mode: 0o700 });
|
||||
chmodSync(launcher, 0o700);
|
||||
return {
|
||||
marker,
|
||||
bridge: createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\tht.exe",
|
||||
spawnChild: (_executable, args, options) => spawn(launcher, [...args], options),
|
||||
...(mode === "stdin" ? {
|
||||
beforeInputForTest: async () => {
|
||||
await waitForMarker(marker, "stdin-closed");
|
||||
appendFileSync(marker, "before-input\n");
|
||||
},
|
||||
} : {}),
|
||||
} as never),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of fixtureRoots.splice(0)) {
|
||||
const marker = join(directory, "marker.txt");
|
||||
try {
|
||||
const pid = Number(/^started:(\d+)$/m.exec(readFileSync(marker, "utf8"))?.[1]);
|
||||
if (Number.isSafeInteger(pid) && pid > 0) process.kill(pid, "SIGKILL");
|
||||
} catch { /* the test-owned child already exited or did not start */ }
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
class FakeBridgeChild extends EventEmitter {
|
||||
readonly stdin = new PassThrough();
|
||||
@@ -110,6 +163,52 @@ describe("Windows auth-storage bridge", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses the Go helper's required empty entries array", async () => {
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\tht.exe",
|
||||
invoke: async () => ({
|
||||
code: 0,
|
||||
stdout: Buffer.from('{"version":1,"ok":true,"entries":[]}\n'),
|
||||
stderr: Buffer.alloc(0),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(bridge.list(root, "sessions")).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("allows only canonical OIDC claim removal and rejects claims elsewhere", async () => {
|
||||
const claim = `${"b".repeat(64)}.claim`;
|
||||
const invoke = vi.fn(async () => ({
|
||||
code: 0,
|
||||
stdout: Buffer.from('{"version":1,"ok":true,"removed":true}\n'),
|
||||
stderr: Buffer.alloc(0),
|
||||
}));
|
||||
const bridge = createWindowsAuthStorageBridge({ thtExecutable: "C:\\tht.exe", invoke });
|
||||
|
||||
await expect(bridge.remove(root, "oidc", claim)).resolves.toBe(true);
|
||||
await expect(bridge.remove(root, "sessions", claim)).rejects.toThrow("auth_session_store_invalid");
|
||||
await expect(bridge.read(root, "oidc", claim)).rejects.toThrow("auth_session_store_invalid");
|
||||
await expect(bridge.create(root, "oidc", claim, Buffer.from("record"))).rejects.toThrow("auth_session_store_invalid");
|
||||
await expect(bridge.replace(root, "oidc", claim, Buffer.from("record"))).rejects.toThrow("auth_session_store_invalid");
|
||||
await expect(bridge.remove(root, "oidc", `../${claim}`)).rejects.toThrow("auth_session_store_invalid");
|
||||
await expect(bridge.remove(root, "oidc", `${claim}.bak`)).rejects.toThrow("auth_session_store_invalid");
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("rejects OIDC claim entries returned for a sessions list", async () => {
|
||||
const claim = `${"c".repeat(64)}.claim`;
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\tht.exe",
|
||||
invoke: async () => ({
|
||||
code: 0,
|
||||
stdout: Buffer.from(`{"version":1,"ok":true,"entries":[{"name":"${claim}","modifiedUnixMs":1}]}\n`),
|
||||
stderr: Buffer.alloc(0),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(bridge.list(root, "sessions")).rejects.toThrow("auth_session_store_invalid");
|
||||
});
|
||||
|
||||
test("aborts a stdin-closed looping helper on timeout and waits for close", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = new FakeBridgeChild();
|
||||
@@ -189,4 +288,17 @@ describe("Windows auth-storage bridge", () => {
|
||||
|
||||
await expect(bridge.list(root, "sessions")).rejects.toThrow("auth_session_store_invalid");
|
||||
});
|
||||
|
||||
test.each(["timeout", "stdout", "stderr", "stdin"] as const)("kills a real %s helper process through the production spawn path", async (mode) => {
|
||||
const { bridge, marker } = realChildBridge(mode);
|
||||
const startedAt = Date.now();
|
||||
const pending = bridge.list(root, "sessions");
|
||||
const outcome = pending.then(() => undefined, (error: unknown) => error);
|
||||
await waitForMarker(marker, "started");
|
||||
if (mode === "stdin") await waitForMarker(marker, "before-input");
|
||||
|
||||
await expect(outcome).resolves.toMatchObject({ message: "auth_session_store_invalid" });
|
||||
await waitForMarker(marker, "terminated");
|
||||
if (mode === "stdin") expect(Date.now() - startedAt).toBeLessThan(2_000);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
@@ -41,15 +41,15 @@ type request struct {
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Version int `json:"version"`
|
||||
OK bool `json:"ok"`
|
||||
Created bool `json:"created,omitempty"`
|
||||
Replaced bool `json:"replaced,omitempty"`
|
||||
Removed bool `json:"removed,omitempty"`
|
||||
Found bool `json:"found,omitempty"`
|
||||
Claimed bool `json:"claimed,omitempty"`
|
||||
ContentBase64 string `json:"contentBase64,omitempty"`
|
||||
Entries []safeio.PrivateDirectoryEntry `json:"entries,omitempty"`
|
||||
Version int `json:"version"`
|
||||
OK bool `json:"ok"`
|
||||
Created bool `json:"created,omitempty"`
|
||||
Replaced bool `json:"replaced,omitempty"`
|
||||
Removed bool `json:"removed,omitempty"`
|
||||
Found bool `json:"found,omitempty"`
|
||||
Claimed bool `json:"claimed,omitempty"`
|
||||
ContentBase64 string `json:"contentBase64,omitempty"`
|
||||
Entries *[]safeio.PrivateDirectoryEntry `json:"entries,omitempty"`
|
||||
}
|
||||
|
||||
// Run accepts exactly one strict JSON request on stdin and emits exactly one JSON response on
|
||||
@@ -96,7 +96,7 @@ func fail(stderr io.Writer) int {
|
||||
}
|
||||
|
||||
func execute(input request) (response, error) {
|
||||
if input.Version != protocolVersion || !validDirectory(input.Directory) {
|
||||
if input.Version != protocolVersion || !validDirectory(input.Directory) || !validOperationShape(input) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
directory, err := storageDirectory(input.Root, input.Directory)
|
||||
@@ -106,7 +106,7 @@ func execute(input request) (response, error) {
|
||||
switch input.Operation {
|
||||
case "create":
|
||||
contents, err := decodeContents(input)
|
||||
if err != nil || !digestFilename.MatchString(input.Filename) {
|
||||
if err != nil {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
created, err := createPrivate(directory, input.Filename, contents)
|
||||
@@ -115,9 +115,6 @@ func execute(input request) (response, error) {
|
||||
}
|
||||
return response{Version: protocolVersion, OK: true, Created: created}, nil
|
||||
case "read":
|
||||
if !digestFilename.MatchString(input.Filename) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
contents, found, err := readPrivate(directory, input.Filename, recordMaximum(input.Directory))
|
||||
if err != nil {
|
||||
return response{}, errInvalid
|
||||
@@ -125,7 +122,7 @@ func execute(input request) (response, error) {
|
||||
return contentResponse(found, contents), nil
|
||||
case "replace":
|
||||
contents, err := decodeContents(input)
|
||||
if err != nil || !digestFilename.MatchString(input.Filename) {
|
||||
if err != nil {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
if err := safeio.ReplaceCanonicalRegular(filepath.Join(directory, input.Filename), contents, 0o600); err != nil {
|
||||
@@ -133,9 +130,6 @@ func execute(input request) (response, error) {
|
||||
}
|
||||
return response{Version: protocolVersion, OK: true, Replaced: true}, nil
|
||||
case "remove":
|
||||
if !digestFilename.MatchString(input.Filename) && !claimFilename.MatchString(input.Filename) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
removed, err := removePrivate(directory, input.Filename)
|
||||
if err != nil {
|
||||
return response{}, errInvalid
|
||||
@@ -147,20 +141,14 @@ func execute(input request) (response, error) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !digestFilename.MatchString(entry.Name) && !claimFilename.MatchString(entry.Name) {
|
||||
if !digestFilename.MatchString(entry.Name) && !(input.Directory == "oidc" && claimFilename.MatchString(entry.Name)) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
}
|
||||
return response{Version: protocolVersion, OK: true, Entries: entries}, nil
|
||||
return response{Version: protocolVersion, OK: true, Entries: &entries}, nil
|
||||
case "claim-consume":
|
||||
if input.Directory != "oidc" || !digestFilename.MatchString(input.Filename) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
return claimConsume(directory, input.Filename)
|
||||
case "read-claim":
|
||||
if input.Directory != "oidc" || !digestFilename.MatchString(input.Filename) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
contents, found, err := safeio.ReadCanonicalPrivateClaim(
|
||||
filepath.Join(directory, input.Filename),
|
||||
filepath.Join(directory, asClaimFilename(input.Filename)),
|
||||
@@ -171,9 +159,6 @@ func execute(input request) (response, error) {
|
||||
}
|
||||
return contentResponse(found, contents), nil
|
||||
case "remove-claim":
|
||||
if input.Directory != "oidc" || !digestFilename.MatchString(input.Filename) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
removed, err := safeio.RemoveCanonicalPrivateClaim(
|
||||
filepath.Join(directory, input.Filename),
|
||||
filepath.Join(directory, asClaimFilename(input.Filename)),
|
||||
@@ -187,6 +172,24 @@ func execute(input request) (response, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func validOperationShape(input request) bool {
|
||||
noContents := input.ContentBase64 == ""
|
||||
switch input.Operation {
|
||||
case "create", "replace":
|
||||
return digestFilename.MatchString(input.Filename)
|
||||
case "read":
|
||||
return noContents && digestFilename.MatchString(input.Filename)
|
||||
case "remove":
|
||||
return noContents && (digestFilename.MatchString(input.Filename) || (input.Directory == "oidc" && claimFilename.MatchString(input.Filename)))
|
||||
case "list":
|
||||
return input.Filename == "" && noContents
|
||||
case "claim-consume", "read-claim", "remove-claim":
|
||||
return input.Directory == "oidc" && noContents && digestFilename.MatchString(input.Filename)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func contentResponse(found bool, contents []byte) response {
|
||||
if !found {
|
||||
return response{Version: protocolVersion, OK: true}
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestProtocolCreatesReadsReplacesListsAndRemovesPrivateRecord(t *testing.T)
|
||||
t.Fatal("replace did not report success")
|
||||
}
|
||||
listed := runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})
|
||||
if len(listed.Entries) != 1 || listed.Entries[0].Name != filename {
|
||||
if listed.Entries == nil || len(*listed.Entries) != 1 || (*listed.Entries)[0].Name != filename {
|
||||
t.Fatalf("list = %#v, want exactly %q", listed.Entries, filename)
|
||||
}
|
||||
removed := runRequest(t, request{Version: 1, Operation: "remove", Root: root, Directory: "sessions", Filename: filename})
|
||||
@@ -61,6 +61,49 @@ func TestProtocolListSerializesLowerCamelBridgeDTO(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolListAlwaysSerializesAnEmptyEntriesArray(t *testing.T) {
|
||||
root := filepath.Join(privateTestRoot(t), "auth")
|
||||
input, err := json.Marshal(request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := Run(context.Background(), nil, bytes.NewReader(input), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("Run() code = %d stderr = %q", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"entries":[]`) {
|
||||
t.Fatalf("empty list bridge JSON = %q, want entries array", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolPermitsClaimNamesOnlyForOIDCRemove(t *testing.T) {
|
||||
root := filepath.Join(privateTestRoot(t), "auth")
|
||||
digest := "abababababababababababababababababababababababababababababababab.json"
|
||||
claim := strings.TrimSuffix(digest, ".json") + ".claim"
|
||||
for _, directory := range []string{"sessions", "oidc"} {
|
||||
runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: directory})
|
||||
}
|
||||
if err := safeio.WriteCanonicalNewPrivateFile(filepath.Join(root, "sessions", claim), []byte("orphan"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := safeio.WriteCanonicalNewPrivateFile(filepath.Join(root, "oidc", claim), []byte("orphan"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "sessions", Filename: claim})
|
||||
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", Filename: claim})
|
||||
runRejected(t, request{Version: 1, Operation: "read", Root: root, Directory: "oidc", Filename: claim})
|
||||
runRejected(t, request{Version: 1, Operation: "create", Root: root, Directory: "oidc", Filename: claim, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))})
|
||||
runRejected(t, request{Version: 1, Operation: "replace", Root: root, Directory: "oidc", Filename: claim, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))})
|
||||
runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: "../" + claim})
|
||||
runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: strings.TrimSuffix(claim, ".claim") + ".claim.bak"})
|
||||
|
||||
removed := runRequest(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: claim})
|
||||
if !removed.Removed {
|
||||
t.Fatal("OIDC orphan claim removal did not report success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsProtocolOverflowAndTrailingJSONValues(t *testing.T) {
|
||||
root := filepath.Join(privateTestRoot(t), "auth")
|
||||
valid, err := json.Marshal(request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})
|
||||
|
||||
Reference in New Issue
Block a user