Files
Swifty 7d4a1eff4f fix(auth): compare bearer tokens with fixed padding
## Intent

Fix SYM-28 by removing the length-mismatch branch from bearer-token
comparison. `constantTimeEquals` now pads both UTF-8 byte arrays to a fixed
width, performs one timing-safe comparison, and only then gates the return value
on byte-length equality and compare-size bounds.

## Behavior changes

No user-visible behavior changes. Valid configured bearer tokens are still
accepted, invalid tokens are still rejected, and API auth coverage is unchanged.
The internal comparison path no longer has a distinct length-mismatch branch.

## Validation

- [x] `./scripts/fast-feedback.sh` passed
- [x] Behavior docs updated (if behavior changed)
  - Not applicable; this is an internal security hardening with unchanged
    public behavior.
- [x] `bun test tests/unit/server/auth.test.ts`
- [x] `git diff --check`
- [x] `bun test --timeout 20000 tests/e2e/start-server.e2e.test.ts`
- [x] `bun test --timeout 20000 tests/e2e/cli.e2e.test.ts --test-name-pattern "run records|openclaw commands"`

## Screenshots / video

N/A for CLI-only changes.
2026-04-17 18:07:14 +02:00

103 lines
3.2 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { timingSafeEqual } from "node:crypto";
import {
constantTimeEquals,
extractBearerToken,
TOKEN_COMPARE_BYTE_LENGTH,
verifyBearerToken,
} from "../../../src/runtime/server/auth/token.ts";
describe("server token auth", () => {
test("compares tokens without accepting length mismatches", () => {
expect(constantTimeEquals("secret", "secret")).toBe(true);
expect(constantTimeEquals("secret", "Secret")).toBe(false);
expect(constantTimeEquals("secret", "secret-extra")).toBe(false);
});
test("uses the same padded compare path for length mismatches", () => {
const comparisons: Array<{
leftLength: number;
rightLength: number;
sameReference: boolean;
}> = [];
const compare: typeof timingSafeEqual = (left, right) => {
comparisons.push({
leftLength: left.byteLength,
rightLength: right.byteLength,
sameReference: Object.is(left, right),
});
return timingSafeEqual(left, right);
};
expect(constantTimeEquals("secret", "secret", compare)).toBe(true);
expect(constantTimeEquals("secret", "Secret", compare)).toBe(false);
expect(constantTimeEquals("secret", "secret-extra", compare)).toBe(false);
expect(
constantTimeEquals(
"a".repeat(TOKEN_COMPARE_BYTE_LENGTH + 1),
"a".repeat(TOKEN_COMPARE_BYTE_LENGTH + 1),
compare,
),
).toBe(false);
expect(comparisons).toEqual([
{
leftLength: TOKEN_COMPARE_BYTE_LENGTH,
rightLength: TOKEN_COMPARE_BYTE_LENGTH,
sameReference: false,
},
{
leftLength: TOKEN_COMPARE_BYTE_LENGTH,
rightLength: TOKEN_COMPARE_BYTE_LENGTH,
sameReference: false,
},
{
leftLength: TOKEN_COMPARE_BYTE_LENGTH,
rightLength: TOKEN_COMPARE_BYTE_LENGTH,
sameReference: false,
},
{
leftLength: TOKEN_COMPARE_BYTE_LENGTH,
rightLength: TOKEN_COMPARE_BYTE_LENGTH,
sameReference: false,
},
]);
});
test("extracts bearer headers and EventSource access_token fallback", () => {
const headerRequest = new Request("http://example.test/api/runs", {
headers: { authorization: "Bearer abc123" },
});
const eventSourceRequest = new Request(
"http://example.test/api/runs/run-1/events?access_token=sse-token",
);
expect(extractBearerToken(headerRequest)).toBe("abc123");
expect(extractBearerToken(eventSourceRequest)).toBe("sse-token");
});
test("protects API paths while leaving health and static routes public", () => {
expect(
verifyBearerToken(new Request("http://example.test/healthz"), "secret"),
).toBe(true);
expect(
verifyBearerToken(new Request("http://example.test/readyz"), "secret"),
).toBe(true);
expect(
verifyBearerToken(new Request("http://example.test/"), "secret"),
).toBe(true);
expect(
verifyBearerToken(new Request("http://example.test/api/runs"), "secret"),
).toBe(false);
expect(
verifyBearerToken(
new Request("http://example.test/api/runs", {
headers: { authorization: "Bearer secret" },
}),
"secret",
),
).toBe(true);
});
});