fix: validate state for manual Chutes OAuth

This commit is contained in:
Peter Steinberger
2026-02-14 23:01:28 +01:00
parent 937e1c21f2
commit a99ad11a41
6 changed files with 69 additions and 58 deletions

View File

@@ -2,53 +2,51 @@ import { describe, expect, it } from "vitest";
import { generateChutesPkce, parseOAuthCallbackInput } from "./chutes-oauth.js";
describe("parseOAuthCallbackInput", () => {
const EXPECTED_STATE = "abc123def456";
it("rejects code-only input (state required)", () => {
const parsed = parseOAuthCallbackInput("abc123", "expected-state");
expect(parsed).toEqual({
error: "Paste the full redirect URL (must include code + state).",
});
});
it("returns code and state for valid URL with matching state", () => {
const result = parseOAuthCallbackInput(
`http://localhost/cb?code=authcode_xyz&state=${EXPECTED_STATE}`,
EXPECTED_STATE,
it("accepts full redirect URL when state matches", () => {
const parsed = parseOAuthCallbackInput(
"http://127.0.0.1:1456/oauth-callback?code=abc123&state=expected-state",
"expected-state",
);
expect(result).toEqual({ code: "authcode_xyz", state: EXPECTED_STATE });
expect(parsed).toEqual({ code: "abc123", state: "expected-state" });
});
it("rejects URL with mismatched state (CSRF protection)", () => {
const result = parseOAuthCallbackInput(
"http://localhost/cb?code=authcode_xyz&state=attacker_state",
EXPECTED_STATE,
it("accepts querystring-only input when state matches", () => {
const parsed = parseOAuthCallbackInput("code=abc123&state=expected-state", "expected-state");
expect(parsed).toEqual({ code: "abc123", state: "expected-state" });
});
it("rejects missing state", () => {
const parsed = parseOAuthCallbackInput(
"http://127.0.0.1:1456/oauth-callback?code=abc123",
"expected-state",
);
expect(result).toHaveProperty("error");
expect((result as { error: string }).error).toMatch(/state mismatch/i);
expect(parsed).toEqual({
error: "Missing 'state' parameter. Paste the full redirect URL.",
});
});
it("accepts bare code input for manual flow", () => {
const result = parseOAuthCallbackInput("bare_auth_code", EXPECTED_STATE);
expect(result).toEqual({ code: "bare_auth_code", state: EXPECTED_STATE });
});
it("rejects empty input", () => {
const result = parseOAuthCallbackInput("", EXPECTED_STATE);
expect(result).toEqual({ error: "No input provided" });
});
it("rejects URL missing code parameter", () => {
const result = parseOAuthCallbackInput(
`http://localhost/cb?state=${EXPECTED_STATE}`,
EXPECTED_STATE,
it("rejects state mismatch", () => {
const parsed = parseOAuthCallbackInput(
"http://127.0.0.1:1456/oauth-callback?code=abc123&state=evil",
"expected-state",
);
expect(result).toHaveProperty("error");
});
it("rejects URL missing state parameter", () => {
const result = parseOAuthCallbackInput("http://localhost/cb?code=authcode_xyz", EXPECTED_STATE);
expect(result).toHaveProperty("error");
expect(parsed).toEqual({
error: "OAuth state mismatch - possible CSRF attack. Please retry login.",
});
});
});
describe("generateChutesPkce", () => {
it("returns verifier and challenge strings", () => {
it("returns verifier and challenge", () => {
const pkce = generateChutesPkce();
expect(pkce.verifier).toMatch(/^[0-9a-f]{64}$/);
expect(pkce.challenge).toBeTruthy();
expect(pkce.challenge).toMatch(/^[A-Za-z0-9_-]+$/);
});
});

View File

@@ -42,28 +42,42 @@ export function parseOAuthCallbackInput(
return { error: "No input provided" };
}
// Manual flow must validate CSRF state; require URL (or querystring) that includes `state`.
let url: URL;
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code) {
return { error: "Missing 'code' parameter in URL" };
}
if (!state) {
return { error: "Missing 'state' parameter. Paste the full URL (or just the code)." };
}
if (state !== expectedState) {
return { error: "OAuth state mismatch - possible CSRF attack. Please retry login." };
}
return { code, state };
url = new URL(trimmed);
} catch {
// Manual flow: users often paste only the authorization code.
// In that case we can't validate state, but the user is explicitly opting in by pasting it.
if (!/\s/.test(trimmed) && !trimmed.includes("://") && trimmed.length > 0) {
return { code: trimmed, state: expectedState };
// Code-only paste (common) is no longer accepted because it defeats state validation.
if (
!/\s/.test(trimmed) &&
!trimmed.includes("://") &&
!trimmed.includes("?") &&
!trimmed.includes("=")
) {
return { error: "Paste the full redirect URL (must include code + state)." };
}
// Users sometimes paste only the query string: `?code=...&state=...` or `code=...&state=...`
const qs = trimmed.startsWith("?") ? trimmed : `?${trimmed}`;
try {
url = new URL(`http://localhost/${qs}`);
} catch {
return { error: "Paste the full redirect URL (must include code + state)." };
}
return { error: "Paste the redirect URL (or authorization code)." };
}
const code = url.searchParams.get("code")?.trim();
const state = url.searchParams.get("state")?.trim();
if (!code) {
return { error: "Missing 'code' parameter in URL" };
}
if (!state) {
return { error: "Missing 'state' parameter. Paste the full redirect URL." };
}
if (state !== expectedState) {
return { error: "OAuth state mismatch - possible CSRF attack. Please retry login." };
}
return { code, state };
}
function coerceExpiresAt(expiresInSeconds: number, now: number): number {