Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/services/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,11 @@ export async function processSubmitDraft(env: Env, draftId: string): Promise<voi
.bind(draftId)
.first<{ encrypted_token: string; expires_at: string; consumed_at: string | null }>();
const { encKey } = draftSecrets(env);
if (!tokenRow || tokenRow.consumed_at || new Date(tokenRow.expires_at).getTime() < Date.now() || !encKey) {
// Fail closed on an unparseable expiry: new Date(...).getTime() -> NaN makes `NaN < Date.now()` false,
// which would otherwise treat a token whose stored expires_at is malformed/empty as never expired.
// Mirrors src/auth/security.ts' Number.isFinite(expiresAtMs) session-expiry guard.
const expiresAtMs = tokenRow ? Date.parse(tokenRow.expires_at) : NaN;
if (!tokenRow || tokenRow.consumed_at || !Number.isFinite(expiresAtMs) || expiresAtMs < Date.now() || !encKey) {
await env.DB.prepare(`UPDATE submission_drafts SET status = 'error', last_error = 'token_unavailable', updated_at = ? WHERE id = ?`)
.bind(new Date().toISOString(), draftId)
.run();
Expand Down
12 changes: 12 additions & 0 deletions test/unit/draft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,18 @@ describe("processSubmitDraft — token expiry / consumed guards + base-SHA + tit
expect(row).toMatchObject({ status: "error", last_error: "token_unavailable" });
});

it("marks the draft error 'token_unavailable' when expires_at is unparseable (fail-closed)", async () => {
// A malformed expires_at must NOT be treated as "never expired"; it short-circuits to token_unavailable.
const env = draftEnv();
const id = await seedQueuedDraftWithToken(env, SAMPLE_FIELDS, { expiresAt: "not-a-valid-date" });
const fetchSpy = vi.spyOn(globalThis, "fetch");
await processSubmitDraft(env, id);
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
const row = await env.DB.prepare("SELECT status, last_error FROM submission_drafts WHERE id = ?").bind(id).first<{ status: string; last_error: string }>();
expect(row).toMatchObject({ status: "error", last_error: "token_unavailable" });
});

it("marks the draft error 'token_unavailable' when the token is already consumed (consumed guard)", async () => {
const env = draftEnv();
const id = await seedQueuedDraftWithToken(env, SAMPLE_FIELDS, { consumed: true });
Expand Down
Loading