Compare commits

...
Author SHA1 Message Date
CodeWhale Bot 6ec8176f97 chore(changelog): document maintainer and cron secret rules 2026-11-13 05:34:06 -08:00
CodeWhale Bot d544046fb3 web: refuse periodic repeated patterns in maintainer and cron secrets
Update secretStrongEnough to reject values that repeat a pattern with a period up to half the secret length, closing repeated short strings that pass the 8-distinct-character floor while leaving random hex, UUID and base64 secrets accepted. Update the 503 unavailability messages, operator docs and weak-secret test cases accordingly.

Checks: web npm test (7 node + 1316 vitest in 92 files), npx tsc --noEmit, npm run lint, npm run check:docs and npm run check pass.
2026-11-13 05:34:05 -08:00
CodeWhale BotandClaude Sonnet 5.5 06a5f9bbe1 web: accept hex and UUID secrets, and refuse secrets that contain lone surrogates
The distinct-character floor for the maintainer token and the cron secret is now 8
instead of 16, so a random hex secret (16 possible symbols) or a UUID is accepted.
A secret containing an unpaired UTF-16 surrogate is refused, since such a value is
encoded as the replacement character when it is compared. The documentation states
the new floor and gives a command for generating a secret.

Checks: web npm test 7 node + 1316 vitest in 92 files, tsc, lint and check:docs pass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-13 05:34:03 -08:00
CodeWhale BotandClaude Sonnet 5.5 bed977ba04 web: bind admin sessions to the maintainer token and apply one strength rule to both secrets
A session now records a SHA-256 fingerprint of the MAINTAINER_TOKEN it was
issued under, and validation requires that fingerprint to match the token
currently configured. Changing the token therefore ends every open admin
session, sessions stored without a fingerprint are no longer accepted, and
a session is only issued when the configured token meets the minimum. The
draft post route, merch operator route, merch interest list and admin page
pass the configured token to validation; the extra length checks added at
those gates are dropped because validation now covers them.

The minimum is one rule shared by MAINTAINER_TOKEN and CRON_SECRET: 32 to
512 characters, counted by code point, with at least 16 distinct
characters. 512 matches the sign-in form's submit limit. A CRON_SECRET that
does not meet it leaves the manual cron trigger answering 503 with the
requirement, before the limiter or the comparison. The admin page logs the
requirement when the configured token is too weak, so operators can find it
in the Worker logs.

Tests: session binding and legacy records (community-agent), rotation at
login and at the merch interest list, weak token and weak cron secret
patterns, and the exact minimum. Web suite 1314 passed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-13 05:34:02 -08:00
CodeWhale BotandClaude Sonnet 5.5 b6b3013e7e web: rate limit the manual cron trigger and require a 32-character maintainer token
The manual /api/cron trigger now consults the shared rate limiter under its
own key before comparing the submitted secret, answers 429 with Retry-After
when the budget is spent, and answers 503 when the limiter is missing or
fails, so the secret is never compared without a bound on attempts.

Maintainer sign-in treats a MAINTAINER_TOKEN shorter than 32 characters as
unavailable: the login route answers 503 with a message naming the minimum,
and the admin page, the draft post route, the merch operator route and the
merch interest list apply the same check so an existing session stops being
honoured while the configured token is too short.

Tests: cron route (5), admin login (6, two new), merch interest (one new);
existing fixtures that used short tokens now use 32+ characters.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-13 05:34:00 -08:00
21 changed files with 289 additions and 60 deletions

No files matched your search

+1
View File
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
- **Website maintainer and cron secrets:** the manual cron trigger is rate limited, admin sign-in and the cron secret require a 32 to 512 character secret with at least 8 distinct characters that is not a repeated pattern, and changing the maintainer token signs out existing admin sessions.
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
+1
View File
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
- **Website maintainer and cron secrets:** the manual cron trigger is rate limited, admin sign-in and the cron secret require a 32 to 512 character secret with at least 8 distinct characters that is not a repeated pattern, and changing the maintainer token signs out existing admin sessions.
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
+9 -2
View File
@@ -9,14 +9,21 @@ GITHUB_TOKEN=
GITHUB_REPO=codewhale-hq/CodeWhale
# Optional — required to manually invoke /api/cron
# (cloudflare cron triggers don't need this; they set cf-cron).
# (cloudflare cron triggers don't need this; they set cf-cron). When set it must
# be 32 to 512 characters with at least 8 distinct, and a shorter value repeated
# is refused; otherwise the trigger stays disabled.
# Generate one with: openssl rand -base64 33
CRON_SECRET=
# Optional — defaults to deepseek-flash.
DEEPSEEK_MODEL=deepseek-flash
DEEPSEEK_BASE_URL=https://api.deepseek.com
# Admin panel auth. Set to a random secret; enter it in the /admin login form.
# Admin panel auth. Set to a random secret of 32 to 512 characters with at
# least 8 distinct characters, and a shorter value repeated is refused (anything
# else leaves admin sign-in disabled); enter it in the /admin login form.
# Changing it signs out open admin sessions.
# Generate one with: openssl rand -base64 33
MAINTAINER_TOKEN=
# GitHub PAT for posting comments via /admin. Needs issues:write scope.
+5 -3
View File
@@ -87,8 +87,8 @@ form. The form posts to `/api/admin/login`; the token never goes in the URL.
|---|---|---|
| `DEEPSEEK_API_KEY` | Yes | DeepSeek API key for the community agent |
| `GITHUB_TOKEN` | Optional | Fine-grained PAT for GitHub API (raises rate limit) |
| `CRON_SECRET` | Optional | Shared secret for manual cron invocation |
| `MAINTAINER_TOKEN` | Optional | Auth token for /admin panel |
| `CRON_SECRET` | Optional | Shared secret for manual cron invocation (32 to 512 characters, at least 8 distinct, and a shorter value repeated is refused; otherwise the trigger stays disabled); attempts are rate limited (5 per minute per location) |
| `MAINTAINER_TOKEN` | Optional | Auth token for /admin panel; 32 to 512 characters with at least 8 distinct, and a shorter value repeated is refused (otherwise sign-in stays disabled); changing it signs out open sessions |
| `MAINTAINER_GITHUB_PAT` | Optional | GitHub PAT with `issues:write` scope for posting comments |
## Initial deployment
@@ -103,7 +103,9 @@ One-time setup before the first `npm run deploy`:
Copy the returned `id` values into the matching `CURATED_KV` and
`NEXT_INC_CACHE_KV` bindings in `cloudflare.config.ts`.
2. **Set secrets:**
2. **Set secrets:** generate `MAINTAINER_TOKEN` and `CRON_SECRET` with
`openssl rand -base64 33` (any random value of 32 to 512 characters with at
least 8 distinct and not a shorter value repeated works; `openssl rand -hex 32` is fine).
```bash
npx wrangler secret put DEEPSEEK_API_KEY
npx wrangler secret put MAINTAINER_TOKEN
+2 -2
View File
@@ -20,10 +20,10 @@ Env (mirrors `.env.example`):
| `DEEPSEEK_API_KEY` | DeepSeek platform key (`sk-...`) | only for the `/api/cron` tasks (summarization + community agent) |
| `GITHUB_TOKEN` | Fine-grained PAT, public-repo read scope | optional (raises rate limit 60 → 5000 req/h) |
| `GITHUB_REPO` | Defaults to `codewhale-hq/CodeWhale` | optional |
| `CRON_SECRET` | Shared secret for manual `/api/cron` invocation | optional (Cloudflare cron triggers don't need it) |
| `CRON_SECRET` | Shared secret for manual `/api/cron` invocation (32-512 characters, 8+ distinct; a shorter value repeated is refused)| optional (Cloudflare cron triggers don't need it) |
| `DEEPSEEK_MODEL` | Defaults to `deepseek-flash` | optional |
| `DEEPSEEK_BASE_URL` | Defaults to `https://api.deepseek.com` | optional |
| `MAINTAINER_TOKEN` | Admin panel auth; enter it in the `/admin` login form | only for `/admin` |
| `MAINTAINER_TOKEN` | Admin panel auth (32-512 characters, 8+ distinct; a shorter value repeated is refused; changing it signs out sessions); enter it in the `/admin` login form | only for `/admin` |
| `MAINTAINER_GITHUB_PAT` | PAT with `issues:write`, for posting comments via `/admin` | only for `/admin` posting |
| `NEXT_PUBLIC_GITEE_ENABLED` | Set to `1` once the Gitee mirror exists; blank hides Gitee links | optional |
+4 -2
View File
@@ -4,6 +4,7 @@ import { ConnectionBanner } from "@/components/connection-banner";
import { EmptyState, ErrorState } from "@/components/surface-state";
import { WhalePose } from "@/components/whale-pose";
import { getAgentEnv, listDrafts, validateSession, type AgentDraft } from "@/lib/community-agent";
import { MAINTAINER_TOKEN_UNAVAILABLE, maintainerTokenUsable } from "@/lib/maintainer-token";
import { AdminClient } from "./admin-client";
export const dynamic = "force-dynamic";
@@ -77,7 +78,8 @@ export default async function AdminPage({
const env = await getAgentEnv();
if (!env.MAINTAINER_TOKEN) {
if (!maintainerTokenUsable(env.MAINTAINER_TOKEN)) {
if (env.MAINTAINER_TOKEN) console.warn(MAINTAINER_TOKEN_UNAVAILABLE);
return (
<div className="route-state">
<ConnectionBanner locale={locale} />
@@ -98,7 +100,7 @@ export default async function AdminPage({
const cookieStore = await cookies();
const sid = cookieStore.get("mt_sid")?.value;
const authed = await validateSession(env.CURATED_KV, sid);
const authed = await validateSession(env.CURATED_KV, sid, env.MAINTAINER_TOKEN);
if (!authed) {
return <LoginForm locale={locale} error={err === "1"} />;
+5 -5
View File
@@ -1,12 +1,12 @@
import { NextResponse } from "next/server";
import { getAgentEnv, safeEqual, createSession } from "@/lib/community-agent";
import { MAINTAINER_TOKEN_UNAVAILABLE, MAX_SECRET_CHARS, maintainerTokenUsable } from "@/lib/maintainer-token";
import { FormBodyError, readBoundedUrlEncodedForm } from "@/lib/bounded-form";
export const dynamic = "force-dynamic";
const ALLOWED_LOCALES = new Set(["en", "zh"]);
const MAX_LOGIN_BODY_BYTES = 4_096;
const MAX_TOKEN_CHARS = 512;
function pickLocale(value: string | null | undefined): string {
if (!value) return "en";
@@ -18,8 +18,8 @@ export async function POST(req: Request) {
const url = new URL(req.url);
const localeFromQuery = pickLocale(url.searchParams.get("locale"));
if (!env.MAINTAINER_TOKEN) {
return new NextResponse("Not configured", {
if (!maintainerTokenUsable(env.MAINTAINER_TOKEN)) {
return new NextResponse(MAINTAINER_TOKEN_UNAVAILABLE, {
status: 503,
headers: { "Cache-Control": "no-store" },
});
@@ -58,7 +58,7 @@ export async function POST(req: Request) {
}
const submitted = form.get("token") ?? "";
const locale = pickLocale(form.get("locale") ?? localeFromQuery);
if (submitted.length > MAX_TOKEN_CHARS) {
if (submitted.length > MAX_SECRET_CHARS) {
return new NextResponse("Token too long", {
status: 413,
headers: { "Cache-Control": "no-store" },
@@ -73,7 +73,7 @@ export async function POST(req: Request) {
});
}
const sid = await createSession(env.CURATED_KV);
const sid = await createSession(env.CURATED_KV, env.MAINTAINER_TOKEN);
if (!sid) {
return new NextResponse("Session storage unavailable", {
status: 503,
+1 -1
View File
@@ -9,7 +9,7 @@ export async function POST(request: Request) {
try {
const authEnv = await getAgentEnv();
const sid = request.headers.get("cookie")?.split(";").map(c => c.trim()).find(c => c.startsWith("mt_sid="))?.slice(7);
if (!authEnv.MAINTAINER_TOKEN || !await validateSession(authEnv.CURATED_KV, sid)) throw new MerchError(401, "unauthorized", "Maintainer sign-in is required.");
if (!await validateSession(authEnv.CURATED_KV, sid, authEnv.MAINTAINER_TOKEN)) throw new MerchError(401, "unauthorized", "Maintainer sign-in is required.");
const env = await merchEnv(), config = parseConfig(env.MERCH_CONFIG_JSON);
if (!config || request.headers.get("origin") !== new URL(config.siteOrigin).origin || !request.headers.get("content-type")?.startsWith("application/json")) throw new MerchError(403, "request_blocked", "Use the approved store origin.");
const raw = await readBoundedBody(request, 8192);
+1 -1
View File
@@ -39,7 +39,7 @@ async function checkAuth(req: Request, env: CommunityAgentEnv): Promise<{ ok: bo
}
}
if (!sid || !(await validateSession(env.CURATED_KV, sid))) {
if (!sid || !(await validateSession(env.CURATED_KV, sid, env.MAINTAINER_TOKEN))) {
return { ok: false, status: 401, error: "unauthorized" };
}
return { ok: true };
+26 -1
View File
@@ -4,6 +4,7 @@ import type { AgentEnv } from "@/lib/community-agent-tasks";
import { runFactsDrift } from "@/lib/facts-drift";
import { runLinkCheck, runSemanticDrift } from "@/lib/content-watch";
import { safeEqual } from "@/lib/community-agent";
import { CRON_SECRET_UNAVAILABLE, secretStrongEnough } from "@/lib/maintainer-token";
export const dynamic = "force-dynamic";
@@ -30,9 +31,33 @@ export async function GET(req: Request) {
);
}
if (!secretStrongEnough(env.CRON_SECRET)) {
console.warn(CRON_SECRET_UNAVAILABLE);
return NextResponse.json(
{ error: CRON_SECRET_UNAVAILABLE },
{ status: 503, headers: { "Cache-Control": "no-store" } }
);
}
try {
if (!env.ADMIN_LOGIN_LIMITER) throw new Error("Missing cron limiter");
const { success } = await env.ADMIN_LOGIN_LIMITER.limit({ key: "codewhale-web:cron-secret" });
if (!success) {
return NextResponse.json(
{ error: "too many attempts" },
{ status: 429, headers: { "Cache-Control": "no-store", "Retry-After": "60" } }
);
}
} catch {
return NextResponse.json(
{ error: "manual trigger temporarily unavailable" },
{ status: 503, headers: { "Cache-Control": "no-store", "Retry-After": "60" } }
);
}
const auth = req.headers.get("x-cron-secret") ?? "";
if (!(await safeEqual(auth, env.CRON_SECRET))) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
return NextResponse.json({ error: "unauthorized" }, { status: 401, headers: { "Cache-Control": "no-store" } });
}
const { searchParams } = new URL(req.url);
+10 -4
View File
@@ -14,7 +14,7 @@ vi.mock("@/lib/community-agent", async (importOriginal) => {
import { POST } from "@/app/api/admin/login/route";
import { createSession, safeEqual, validateSession } from "@/lib/community-agent";
const FIXTURE_TOKEN = "local-admin-integration-fixture";
const FIXTURE_TOKEN = "local-admin-integration-fixture-0123456789";
const sessions = new Map<string, string>();
const kv = {
put: vi.fn(async (key: string, value: string) => { sessions.set(key, value); }),
@@ -57,9 +57,9 @@ describe("admin login with real credential and session helpers", () => {
const sid = response.cookies.get("mt_sid")?.value;
expect(sid).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(sessions.size).toBe(1);
expect(await validateSession(sessionKv, sid)).toBe(true);
expect(await validateSession(sessionKv, sid, FIXTURE_TOKEN)).toBe(true);
expect(safeEqual).toHaveBeenCalledExactlyOnceWith(FIXTURE_TOKEN, FIXTURE_TOKEN);
expect(createSession).toHaveBeenCalledExactlyOnceWith(sessionKv);
expect(createSession).toHaveBeenCalledExactlyOnceWith(sessionKv, FIXTURE_TOKEN);
const cookie = response.headers.get("Set-Cookie")?.toLowerCase();
expect(cookie).toContain("httponly");
expect(cookie).toContain("secure");
@@ -69,7 +69,13 @@ describe("admin login with real credential and session helpers", () => {
expect.any(String), expect.any(String), { expirationTtl: 86400 },
]);
sessions.clear();
expect(await validateSession(sessionKv, sid)).toBe(false);
expect(await validateSession(sessionKv, sid, FIXTURE_TOKEN)).toBe(false);
});
it("stops honouring a session after the maintainer token is rotated", async () => {
const sid = (await POST(request())).cookies.get("mt_sid")?.value;
expect(await validateSession(sessionKv, sid, FIXTURE_TOKEN)).toBe(true);
expect(await validateSession(sessionKv, sid, "rotated-integration-fixture-9876543210")).toBe(false);
});
it("rejects a wrong credential without creating a session", async () => {
+38 -4
View File
@@ -5,6 +5,9 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/lib/community-agent", () => mocks);
import { POST } from "@/app/api/admin/login/route";
const STRONG_TOKEN = "local-fixture-token-0123456789-abcdefghij";
const LONE_SURROGATES = Array.from({ length: 16 }, (_, index) => String.fromCharCode(0xd800 + index)).join("").repeat(2);
function request(token = "wrong", ip = "127.0.0.1") {
return new Request("https://codewhale.net/api/admin/login?locale=en", {
method: "POST",
@@ -14,7 +17,7 @@ function request(token = "wrong", ip = "127.0.0.1") {
}
beforeEach(() => {
vi.resetAllMocks();
mocks.getAgentEnv.mockResolvedValue({ MAINTAINER_TOKEN: "configured", ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
mocks.getAgentEnv.mockResolvedValue({ MAINTAINER_TOKEN: STRONG_TOKEN, ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
mocks.limit.mockResolvedValue({ success: true });
mocks.safeEqual.mockResolvedValue(false);
mocks.createSession.mockResolvedValue("session-id");
@@ -35,7 +38,7 @@ describe("admin login attempt boundary", () => {
});
it("does not parse or compare credentials after refusal", async () => {
mocks.limit.mockResolvedValue({ success: false });
const req = request("configured");
const req = request(STRONG_TOKEN);
const read = vi.spyOn(req, "text");
expect((await POST(req)).status).toBe(429);
expect(read).not.toHaveBeenCalled();
@@ -43,16 +46,47 @@ describe("admin login attempt boundary", () => {
expect(mocks.createSession).not.toHaveBeenCalled();
});
it("fails closed if the binding is missing or unavailable", async () => {
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: "configured" });
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: STRONG_TOKEN });
expect((await POST(request())).status).toBe(503);
mocks.limit.mockRejectedValue(new Error("binding unavailable"));
expect((await POST(request())).status).toBe(503);
expect(mocks.safeEqual).not.toHaveBeenCalled();
expect(mocks.createSession).not.toHaveBeenCalled();
});
it("refuses to run with a configured token below the minimum strength", async () => {
const weakTokens = [
"x", "configured", "a".repeat(31), "a".repeat(32), " ".repeat(40), "password".repeat(4),
"Password1".repeat(4), "0123456".repeat(5), "\u{1F433}".repeat(16), "L".repeat(600), LONE_SURROGATES,
];
for (const weak of weakTokens) {
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: weak, ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
mocks.safeEqual.mockResolvedValue(true);
const response = await POST(request(weak));
expect(response.status).toBe(503);
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(await response.text()).toContain("at least 32 characters");
}
expect(mocks.limit).not.toHaveBeenCalled();
expect(mocks.safeEqual).not.toHaveBeenCalled();
expect(mocks.createSession).not.toHaveBeenCalled();
});
it("accepts a token of exactly the minimum length", async () => {
const exact = "abcdefghijklmnopqrstuvwxyz012345";
expect(exact).toHaveLength(32);
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: exact, ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
mocks.safeEqual.mockResolvedValue(true);
expect((await POST(request(exact))).status).toBe(303);
});
it("accepts a 32-character hex secret with fewer than 16 distinct characters", async () => {
const hex = "3f9a3f9a1c7e1c7e3f9a1c7e5b2d5b2d";
expect(new Set(hex).size).toBeLessThan(16);
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: hex, ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
mocks.safeEqual.mockResolvedValue(true);
expect((await POST(request(hex))).status).toBe(303);
});
it("retains the authenticated session and redirect after an allowed attempt", async () => {
mocks.safeEqual.mockResolvedValue(true);
const response = await POST(request("configured"));
const response = await POST(request(STRONG_TOKEN));
expect(response.status).toBe(303);
expect(response.headers.get("Location")).toBe("https://codewhale.net/en/admin");
const cookie = response.headers.get("Set-Cookie");
+1 -1
View File
@@ -144,7 +144,7 @@ function stubAdminEnv(kv: FakeKv, lock?: FakeDraftClaimLock) {
mocks.getAgentEnv.mockResolvedValue({
CURATED_KV: kv,
...(lock ? { DRAFT_CLAIM_LOCK: lock } : {}),
MAINTAINER_TOKEN: "configured",
MAINTAINER_TOKEN: "local-fixture-token-0123456789-abcdefghij",
MAINTAINER_GITHUB_PAT: "ghp_test",
GITHUB_REPO: "codewhale-hq/CodeWhale",
});
+46 -23
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { logUsage, validateSession } from "./community-agent";
import { createSession, logUsage, validateSession } from "./community-agent";
describe("validateSession", () => {
let mockKV: {
@@ -20,65 +20,88 @@ describe("validateSession", () => {
const VALID_SID = "a".repeat(40);
const SESSION_PREFIX = "session:admin:";
const TOKEN = "local-fixture-token-0123456789-abcdefghij";
const ROTATED = "rotated-fixture-token-9876543210-zyxwvutsrq";
it("should return false if kv is undefined", async () => {
const result = await validateSession(undefined, VALID_SID);
const result = await validateSession(undefined, VALID_SID, TOKEN);
expect(result).toBe(false);
});
it("should return false if sid is undefined or null", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
expect(await validateSession(kv, undefined)).toBe(false);
expect(await validateSession(kv, null)).toBe(false);
expect(await validateSession(kv, undefined, TOKEN)).toBe(false);
expect(await validateSession(kv, null, TOKEN)).toBe(false);
});
it("should return false if sid is empty", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
expect(await validateSession(kv, "")).toBe(false);
expect(await validateSession(kv, "", TOKEN)).toBe(false);
});
it("should return false if sid length is less than 40", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
expect(await validateSession(kv, "a".repeat(39))).toBe(false);
expect(await validateSession(kv, "a".repeat(39), TOKEN)).toBe(false);
});
it("should return false if sid length is greater than 64", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
expect(await validateSession(kv, "a".repeat(65))).toBe(false);
expect(await validateSession(kv, "a".repeat(65), TOKEN)).toBe(false);
});
it("should return false if sid contains invalid characters", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
// Length 40 but contains invalid char '!'
expect(await validateSession(kv, "a".repeat(39) + "!")).toBe(false);
expect(await validateSession(kv, "a".repeat(39) + "!", TOKEN)).toBe(false);
});
it("should return false if session is not found in KV", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
mockKV.get.mockResolvedValue(null);
const result = await validateSession(kv, VALID_SID);
const result = await validateSession(kv, VALID_SID, TOKEN);
expect(result).toBe(false);
expect(mockKV.get).toHaveBeenCalledWith(SESSION_PREFIX + VALID_SID);
});
it("should return true if session is found in KV", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
mockKV.get.mockResolvedValue(JSON.stringify({ createdAt: Date.now() }));
const result = await validateSession(kv, VALID_SID);
expect(result).toBe(true);
expect(mockKV.get).toHaveBeenCalledWith(SESSION_PREFIX + VALID_SID);
it("should return true for a session minted under the configured token", async () => {
const store = new Map<string, string>();
const kv = {
get: async (key: string) => store.get(key) ?? null,
put: async (key: string, value: string) => { store.set(key, value); },
} as unknown as Parameters<typeof validateSession>[0];
const sid = await createSession(kv, TOKEN);
expect(sid).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(await validateSession(kv, sid, TOKEN)).toBe(true);
});
it("should work with a 64 character sid", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
mockKV.get.mockResolvedValue("{}");
it("should refuse the same session once the configured token changes", async () => {
const store = new Map<string, string>();
const kv = {
get: async (key: string) => store.get(key) ?? null,
put: async (key: string, value: string) => { store.set(key, value); },
} as unknown as Parameters<typeof validateSession>[0];
const sid = await createSession(kv, TOKEN);
expect(await validateSession(kv, sid, ROTATED)).toBe(false);
expect(await validateSession(kv, sid, "short")).toBe(false);
expect(await validateSession(kv, sid, undefined)).toBe(false);
expect(await validateSession(kv, sid, TOKEN)).toBe(true);
});
const sid = "b".repeat(64);
const result = await validateSession(kv, sid);
expect(result).toBe(true);
expect(mockKV.get).toHaveBeenCalledWith(SESSION_PREFIX + sid);
it("should refuse a stored record with no token fingerprint or unparseable content", async () => {
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
for (const raw of [JSON.stringify({ createdAt: Date.now() }), "{}", "not json", JSON.stringify({ tokenFingerprint: 7 })]) {
mockKV.get.mockResolvedValue(raw);
expect(await validateSession(kv, VALID_SID, TOKEN)).toBe(false);
}
});
it("should not mint a session under a weak token", async () => {
const kv = mockKV as unknown as Parameters<typeof createSession>[0];
for (const weak of [undefined, "", "short", "a".repeat(32)]) {
expect(await createSession(kv, weak)).toBeNull();
}
expect(mockKV.put).not.toHaveBeenCalled();
});
});
+26 -6
View File
@@ -12,6 +12,7 @@
import type { DraftClaimLockNamespace, DraftClaimLockStub, DraftLockAction } from "./draft-claim-lock";
import { maintainerTokenUsable } from "./maintainer-token";
import { requestChat, type ChatMessage, type DeepSeekEnv } from "./deepseek";
export type { DeepSeekEnv } from "./deepseek";
@@ -722,6 +723,7 @@ export async function deleteDraft(kv: KVNamespace | undefined, key: string): Pro
const SESSION_PREFIX = "session:admin:";
const SESSION_TTL_SEC = 60 * 60 * 24; // 24h
const SESSION_TOKEN_DOMAIN = "codewhale-web:admin-session:v1:";
function toBase64Url(bytes: Uint8Array): string {
let s = "";
@@ -738,21 +740,39 @@ export async function safeEqual(a: string, b: string): Promise<boolean> {
return diff === 0;
}
export async function createSession(kv: KVNamespace | undefined): Promise<string | null> {
if (!kv) return null;
async function tokenFingerprint(token: string): Promise<string> {
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(SESSION_TOKEN_DOMAIN + token)));
let hex = "";
for (const byte of digest) hex += byte.toString(16).padStart(2, "0");
return hex;
}
export async function createSession(kv: KVNamespace | undefined, token: string | undefined): Promise<string | null> {
if (!kv || !maintainerTokenUsable(token)) return null;
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const sid = toBase64Url(bytes);
const value = JSON.stringify({ createdAt: Date.now() });
const value = JSON.stringify({ createdAt: Date.now(), tokenFingerprint: await tokenFingerprint(token) });
await kv.put(SESSION_PREFIX + sid, value, { expirationTtl: SESSION_TTL_SEC });
return sid;
}
export async function validateSession(kv: KVNamespace | undefined, sid: string | undefined | null): Promise<boolean> {
if (!kv || !sid) return false;
export async function validateSession(
kv: KVNamespace | undefined,
sid: string | undefined | null,
token: string | undefined,
): Promise<boolean> {
if (!kv || !sid || !maintainerTokenUsable(token)) return false;
if (!/^[A-Za-z0-9_-]{40,64}$/.test(sid)) return false;
const raw = await kv.get(SESSION_PREFIX + sid);
return raw !== null;
if (raw === null) return false;
let stored: unknown;
try {
stored = (JSON.parse(raw) as { tokenFingerprint?: unknown }).tokenFingerprint;
} catch {
return false;
}
return typeof stored === "string" && (await safeEqual(stored, await tokenFingerprint(token)));
}
export async function deleteSession(kv: KVNamespace | undefined, sid: string | undefined | null): Promise<void> {
+76
View File
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ getEnv: vi.fn(), safeEqual: vi.fn(), limit: vi.fn() }));
vi.mock("@/lib/kv", () => ({ getEnv: mocks.getEnv }));
vi.mock("@/lib/community-agent", () => ({ safeEqual: mocks.safeEqual }));
vi.mock("@/lib/facts-drift", () => ({ runFactsDrift: vi.fn() }));
vi.mock("@/lib/content-watch", () => ({ runLinkCheck: vi.fn(), runSemanticDrift: vi.fn() }));
import { GET } from "@/app/api/cron/route";
function request(secret = "wrong", ip = "127.0.0.1") {
return new Request("https://codewhale.net/api/cron?task=nope", {
headers: { "x-cron-secret": secret, "CF-Connecting-IP": ip },
});
}
beforeEach(() => {
vi.resetAllMocks();
mocks.getEnv.mockResolvedValue({ CRON_SECRET: "configured-cron-secret-0123456789-abcdef", ADMIN_LOGIN_LIMITER: { limit: mocks.limit } });
mocks.limit.mockResolvedValue({ success: true });
mocks.safeEqual.mockResolvedValue(false);
});
describe("manual cron trigger attempt boundary", () => {
it("bounds repeated guesses independently of the secret and forged IP", async () => {
let attempts = 0;
mocks.limit.mockImplementation(async () => ({ success: ++attempts <= 5 }));
for (let index = 0; index < 7; index++) {
const response = await GET(request(`guess-${index}`, `192.0.2.${index}`));
expect(response.status).toBe(index < 5 ? 401 : 429);
if (index >= 5) expect(response.headers.get("Retry-After")).toBe("60");
expect(response.headers.get("Cache-Control")).toBe("no-store");
}
expect(mocks.safeEqual).toHaveBeenCalledTimes(5);
expect(mocks.limit.mock.calls.map(([options]) => options.key)).toEqual(Array(7).fill("codewhale-web:cron-secret"));
});
it("does not compare the secret after refusal", async () => {
mocks.limit.mockResolvedValue({ success: false });
expect((await GET(request("configured-cron-secret-0123456789-abcdef"))).status).toBe(429);
expect(mocks.safeEqual).not.toHaveBeenCalled();
});
it("fails closed if the binding is missing or unavailable", async () => {
mocks.getEnv.mockResolvedValueOnce({ CRON_SECRET: "configured-cron-secret-0123456789-abcdef" });
expect((await GET(request())).status).toBe(503);
mocks.limit.mockRejectedValue(new Error("binding unavailable"));
expect((await GET(request())).status).toBe(503);
expect(mocks.safeEqual).not.toHaveBeenCalled();
});
it("stays disabled without a configured secret and never consults the limiter", async () => {
mocks.getEnv.mockResolvedValue({ ADMIN_LOGIN_LIMITER: { limit: mocks.limit } });
expect((await GET(request())).status).toBe(503);
expect(mocks.limit).not.toHaveBeenCalled();
});
it("stays disabled for a secret below the minimum strength and never consults the limiter or compares", async () => {
const loneSurrogates = Array.from({ length: 16 }, (_, index) => String.fromCharCode(0xd800 + index)).join("").repeat(2);
const weak = ["x", "a".repeat(32), " ".repeat(40), "password".repeat(4), "Password1".repeat(4), "\u{1F433}".repeat(16), "0123456".repeat(5), "L".repeat(600), loneSurrogates];
for (const secret of weak) {
mocks.getEnv.mockResolvedValueOnce({ CRON_SECRET: secret, ADMIN_LOGIN_LIMITER: { limit: mocks.limit } });
const response = await GET(request());
expect(response.status).toBe(503);
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(JSON.stringify(await response.json())).toContain("at least 32 characters");
}
expect(mocks.limit).not.toHaveBeenCalled();
expect(mocks.safeEqual).not.toHaveBeenCalled();
});
it("accepts a 32-character hex secret with fewer than 16 distinct characters", async () => {
const hex = "3f9a3f9a1c7e1c7e3f9a1c7e5b2d5b2d";
mocks.getEnv.mockResolvedValueOnce({ CRON_SECRET: hex, ADMIN_LOGIN_LIMITER: { limit: mocks.limit } });
mocks.safeEqual.mockResolvedValue(true);
expect((await GET(request(hex))).status).toBe(400);
});
it("still reaches task validation after an allowed, correct attempt", async () => {
mocks.safeEqual.mockResolvedValue(true);
expect((await GET(request("configured-cron-secret-0123456789-abcdef"))).status).toBe(400);
});
});
+1
View File
@@ -32,6 +32,7 @@ export async function readBoundedKvJson(kv: KVStreamNamespace | undefined, key:
interface CloudflareEnv extends MerchEnv {
DRAFT_CLAIM_LOCK?: DraftClaimLockNamespace;
CURATED_KV?: KVNamespace & KVStreamNamespace;
ADMIN_LOGIN_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> };
MERCH_INTEREST_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> };
MERCH_INTEREST_SITE_ORIGIN?: string;
DEEPSEEK_API_KEY?: string;
+25
View File
@@ -0,0 +1,25 @@
export const MIN_SECRET_CHARS = 32;
export const MAX_SECRET_CHARS = 512;
export const MIN_SECRET_DISTINCT_CHARS = 8;
const LONE_SURROGATE = /\p{Cs}/u;
export const MAINTAINER_TOKEN_UNAVAILABLE =
`Maintainer sign-in is unavailable: MAINTAINER_TOKEN must be set to a secret of at least ${MIN_SECRET_CHARS} characters ` +
`(at most ${MAX_SECRET_CHARS}, with at least ${MIN_SECRET_DISTINCT_CHARS} distinct characters; a shorter value repeated is refused).`;
export const CRON_SECRET_UNAVAILABLE =
`manual trigger disabled: CRON_SECRET must be a secret of at least ${MIN_SECRET_CHARS} characters ` +
`(at most ${MAX_SECRET_CHARS}, with at least ${MIN_SECRET_DISTINCT_CHARS} distinct characters; a shorter value repeated is refused)`;
export function secretStrongEnough(value: string | undefined): value is string {
if (typeof value !== "string" || value.length > MAX_SECRET_CHARS || LONE_SURROGATE.test(value)) return false;
const chars = [...value];
if (chars.length < MIN_SECRET_CHARS || new Set(chars).size < MIN_SECRET_DISTINCT_CHARS) return false;
for (let period = 1; period <= value.length / 2; period++) {
if (value.slice(period) === value.slice(0, value.length - period)) return false;
}
return true;
}
export const maintainerTokenUsable = secretStrongEnough;
+8 -2
View File
@@ -155,8 +155,8 @@ describe("durable merch interest registration", () => {
describe("private merch interest list and deletion", () => {
async function signedIn(): Promise<{ auth: CommunityAgentEnv; headers: Record<string, string> }> {
const auth = { CURATED_KV: env.CURATED_KV, MAINTAINER_TOKEN: "local-maintainer-fixture" };
const sid = await createSession(auth.CURATED_KV);
const auth = { CURATED_KV: env.CURATED_KV, MAINTAINER_TOKEN: "local-fixture-token-0123456789-abcdefghij" };
const sid = await createSession(auth.CURATED_KV, auth.MAINTAINER_TOKEN);
return { auth, headers: { Cookie: "mt_sid=" + sid } };
}
it("requires a real stored maintainer session for reading or deleting contacts", async () => {
@@ -165,6 +165,12 @@ describe("private merch interest list and deletion", () => {
expect(list).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
it.each(["short", "rotated-fixture-token-9876543210-zyxwvutsrq"])("refuses an existing session once the configured maintainer token changes to %s", async (replacement) => {
const { auth, headers } = await signedIn();
const response = await handleAdminMerchInterest(new Request("https://codewhale.net/api/admin/merch/interest", { headers }), env, { ...auth, MAINTAINER_TOKEN: replacement });
expect(response.status).toBe(401);
expect(list).not.toHaveBeenCalled();
});
it("paginates only the private prefix and deletes an opaque id with same-origin CSRF", async () => {
await handleMerchInterest(request(), env);
await handleMerchInterest(request({ ...input(), email: "[email protected]" }), env);
+1 -1
View File
@@ -138,7 +138,7 @@ export async function handleMerchInterest(request: Request, env?: MerchInterestE
}
async function requireAdmin(request: Request, auth: CommunityAgentEnv) {
const sid = request.headers.get("cookie")?.split(";").map(cookie => cookie.trim()).find(cookie => cookie.startsWith("mt_sid="))?.slice(7);
if (!auth.MAINTAINER_TOKEN || !await validateSession(auth.CURATED_KV, sid)) throw new InterestError(401, "unauthorized", "Maintainer sign-in is required.");
if (!await validateSession(auth.CURATED_KV, sid, auth.MAINTAINER_TOKEN)) throw new InterestError(401, "unauthorized", "Maintainer sign-in is required.");
}
export async function handleAdminMerchInterest(request: Request, env?: MerchInterestEnv, authEnv?: CommunityAgentEnv): Promise<Response> {
try {
+2 -2
View File
@@ -73,7 +73,7 @@ describe("public security boundaries", () => {
kv.values.set("agent:state:latest", JSON.stringify({ state: "running" }));
securityMocks.getAgentEnv.mockResolvedValue({
CURATED_KV: kv,
MAINTAINER_TOKEN: "configured",
MAINTAINER_TOKEN: "local-fixture-token-0123456789-abcdefghij",
});
const response = await adminPost(new Request("https://codewhale.net/api/admin/post", {
@@ -121,7 +121,7 @@ describe("public security boundaries", () => {
securityMocks.getAgentEnv.mockResolvedValue({
CURATED_KV: kv,
MAINTAINER_TOKEN: "configured",
MAINTAINER_TOKEN: "local-fixture-token-0123456789-abcdefghij",
MAINTAINER_GITHUB_PAT: "ghp_test",
GITHUB_REPO: "codewhale-hq/CodeWhale",
});