fix: reject credential idempotency headers

This commit is contained in:
DongHyeonka
2026-08-02 01:33:07 +09:00
parent fa2f699125
commit 15645541b7
2 changed files with 56 additions and 0 deletions
+10
View File
@@ -468,6 +468,16 @@ export function createContractHttpExecutor(
// A missing credential never downgrades into an anonymous request.
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
}
if (
Object.keys(patch.headers).some(
(name) => name.toLowerCase() === "idempotency-key",
)
) {
return finish(
violation("UNEXPECTED_IDEMPOTENCY_KEY", "REQUEST", "NOT_STARTED"),
"NOT_STARTED",
);
}
const headers: Record<string, string> = {
Accept: "application/json",
+46
View File
@@ -144,6 +144,52 @@ describe("descriptor-driven HTTP execution lifetime", () => {
},
);
it.each([
{
label: "query with the canonical reserved header",
operation: installed,
input: { limit: 20 },
context: { scope },
headerName: "Idempotency-Key",
},
{
label: "valid KEYED command with a case-variant reserved header",
operation: createInstalled,
input: { name: "created" },
context: { scope, intent: mutationIntent() },
headerName: "iDeMpOtEnCy-KeY",
},
])(
"rejects a credential patch for $label before fetch",
async ({ operation, input, context, headerName }) => {
const attachCredentials = vi.fn(() => ({
kind: "READY" as const,
headers: { [headerName]: "credential-owned-key" },
credentials: "omit" as const,
}));
const fetcher = vi.fn();
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials,
fetcher,
});
await expect(
executor.execute(operation, input, context),
).resolves.toMatchObject({
kind: "CONTRACT_VIOLATION",
violation: {
kind: "UNEXPECTED_IDEMPOTENCY_KEY",
operation: "REQUEST",
},
effect: "NOT_STARTED",
});
expect(attachCredentials).toHaveBeenCalledOnce();
expect(fetcher).not.toHaveBeenCalled();
},
);
it("reuses one supplied idempotency key across every physical retry", async () => {
const observedKeys: Array<string | null> = [];
let attempt = 0;