fix(auth): harden unified diagnostic execution
This commit is contained in:
@@ -136,3 +136,26 @@ test("decodes the shared authentication diagnostics on static validation and liv
|
||||
authentication,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
["ready with error", { ready: true, mode: "oidc", checks: [{ level: "error", code: "oidc_secret_missing", message: "failure" }] }],
|
||||
["failed with ready", { ready: false, mode: "oidc", checks: [{ level: "info", code: "auth_ready", message: "ready" }] }],
|
||||
["failed without error", { ready: false, mode: "oidc", checks: [{ level: "info", code: "auth_config_invalid", message: "info" }] }],
|
||||
["duplicate", { ready: false, mode: "oidc", checks: [
|
||||
{ level: "error", code: "oidc_secret_missing", message: "one" },
|
||||
{ level: "error", code: "oidc_secret_missing", message: "two" },
|
||||
] }],
|
||||
["attacker field", { ready: false, mode: "oidc", checks: [{ level: "error", code: "oidc_secret_missing", message: "failure", field: "attacker-field-SENTINEL" }] }],
|
||||
["control", { ready: false, mode: "oidc", checks: [{ level: "error", code: "oidc_mapped_group_missing", message: "failure", field: "bad\u0085field" }] }],
|
||||
["unexpected property", { ready: false, mode: "oidc", checks: [{ level: "error", code: "oidc_secret_missing", message: "failure", attacker: "field" }] }],
|
||||
])("rejects hostile authentication diagnostics: %s", async (_name, authentication) => {
|
||||
server.use(http.post("/api/workspaces/validate", () => HttpResponse.json({
|
||||
workspace,
|
||||
contract: {},
|
||||
activatable: false,
|
||||
diagnostics: [],
|
||||
authentication,
|
||||
})));
|
||||
|
||||
await expect(validateWorkspace(workspace)).rejects.toThrow("invalid authentication diagnostics");
|
||||
});
|
||||
|
||||
@@ -225,21 +225,36 @@ const authDiagnosticCodes = new Set<AuthDiagnosticCode>([
|
||||
]);
|
||||
|
||||
function text(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 512 && !/[\u0000-\u001f\u007f]/.test(value);
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 512
|
||||
&& value.trim() === value && !/\p{Cc}/u.test(value);
|
||||
}
|
||||
|
||||
function decodeAuthentication(value: unknown): AuthDiagnostics {
|
||||
const source = object(value);
|
||||
const source = exactObject(value, ["ready", "mode", "checks"]);
|
||||
if (!source || typeof source.ready !== "boolean"
|
||||
|| !["local", "oidc", "upstream", "none", "mock"].includes(String(source.mode))
|
||||
|| !Array.isArray(source.checks)) throw new Error("Workspace API returned invalid authentication diagnostics");
|
||||
|| typeof source.mode !== "string"
|
||||
|| !["local", "oidc", "upstream", "none", "mock"].includes(source.mode)
|
||||
|| !Array.isArray(source.checks) || source.checks.length === 0 || source.checks.length > 129) {
|
||||
throw new Error("Workspace API returned invalid authentication diagnostics");
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const checks = source.checks.map((item): AuthDiagnostic => {
|
||||
const check = object(item);
|
||||
const raw = object(item);
|
||||
const check = raw && exactObject(raw, raw.field === undefined
|
||||
? ["level", "code", "message"]
|
||||
: ["level", "code", "message", "field"]);
|
||||
if (!check || (check.level !== "error" && check.level !== "info")
|
||||
|| typeof check.code !== "string" || !authDiagnosticCodes.has(check.code as AuthDiagnosticCode)
|
||||
|| !text(check.message) || (check.field !== undefined && !text(check.field))) {
|
||||
throw new Error("Workspace API returned invalid authentication diagnostics");
|
||||
}
|
||||
if (check.field !== undefined
|
||||
&& check.code !== "oidc_mapped_group_missing" && check.code !== "oidc_mapped_group_ambiguous") {
|
||||
throw new Error("Workspace API returned invalid authentication diagnostics");
|
||||
}
|
||||
const key = `${check.code}\u0000${check.field ?? ""}`;
|
||||
if (seen.has(key)) throw new Error("Workspace API returned invalid authentication diagnostics");
|
||||
seen.add(key);
|
||||
return {
|
||||
level: check.level,
|
||||
code: check.code as AuthDiagnosticCode,
|
||||
@@ -247,6 +262,13 @@ function decodeAuthentication(value: unknown): AuthDiagnostics {
|
||||
...(check.field === undefined ? {} : { field: check.field }),
|
||||
};
|
||||
});
|
||||
if (source.ready) {
|
||||
if (checks.length !== 1 || checks[0].level !== "info" || checks[0].code !== "auth_ready"
|
||||
|| checks[0].field !== undefined) throw new Error("Workspace API returned invalid authentication diagnostics");
|
||||
} else if (!checks.some(({ level }) => level === "error")
|
||||
|| checks.some(({ code }) => code === "auth_ready")) {
|
||||
throw new Error("Workspace API returned invalid authentication diagnostics");
|
||||
}
|
||||
return { ready: source.ready, mode: source.mode as AuthDiagnostics["mode"], checks };
|
||||
}
|
||||
|
||||
|
||||
@@ -250,6 +250,29 @@ test("renders one authentication section with configured-group errors and no unm
|
||||
expect(within(section).queryByText(/unmapped/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("never renders a hostile authentication field rejected by the API decoder", async () => {
|
||||
const user = userEvent.setup();
|
||||
const attacker = "attacker-field-SENTINEL";
|
||||
server.use(http.post("/api/workspaces/validate", () => HttpResponse.json({
|
||||
workspace,
|
||||
contract: {},
|
||||
activatable: false,
|
||||
diagnostics: [],
|
||||
authentication: {
|
||||
ready: false,
|
||||
mode: "oidc",
|
||||
checks: [{ level: "error", code: "oidc_secret_missing", message: "failure", field: attacker }],
|
||||
},
|
||||
})));
|
||||
renderManager();
|
||||
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Validate workspace source" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toBeVisible();
|
||||
expect(screen.queryByText(new RegExp(attacker))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders binding_ok as a green connection success", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
|
||||
@@ -492,7 +492,7 @@ export function WorkspaceManager({
|
||||
{authentication.ready ? "Passed" : "Failed"}
|
||||
</span>
|
||||
</div>
|
||||
{!authentication.ready && <div role="alert" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
{authentication.checks.some(({ level }) => level === "error") && <div role="alert" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
{authentication.checks.filter(({ level }) => level === "error").map(({ code, field, message }) => (
|
||||
<p key={`${code}:${field ?? ""}`} className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />
|
||||
|
||||
Reference in New Issue
Block a user