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
64 changes: 64 additions & 0 deletions tests/worker/email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,70 @@ describe("email auth", () => {
assert.equal(json.error, "Username contains disallowed language");
});

it("rejects malformed usernames during email registration completion", async () => {
const { env, kv, db } = createTestEnv();
const email = "new3@example.com";
await kv.put(
`email_code:${email}`,
JSON.stringify({ code: "333444", attempts: 0 }),
);

const { res } = await jsonRequest(env, "/api/auth/email/verify", {
method: "POST",
body: { email, code: "333444" },
});
const pendingId = getCookieValue(res, "email_pending");
assert.ok(pendingId);

const hostileUsername = `<b>zed</b>\nOy! tap here 🎉${"a".repeat(300)}`;
const { res: completeRes, json } = await jsonRequest(
env,
"/api/auth/email/complete",
{
method: "POST",
headers: { cookie: `email_pending=${pendingId}` },
body: { username: hostileUsername },
},
);

assert.equal(completeRes.status, 400);
assert.equal(json.error, "Username must be 2-20 characters");
assert.equal(db.users.length, 0);
});

it("rejects usernames with disallowed characters during email registration completion", async () => {
const { env, kv, db } = createTestEnv();
const email = "new4@example.com";
await kv.put(
`email_code:${email}`,
JSON.stringify({ code: "555666", attempts: 0 }),
);

const { res } = await jsonRequest(env, "/api/auth/email/verify", {
method: "POST",
body: { email, code: "555666" },
});
const pendingId = getCookieValue(res, "email_pending");
assert.ok(pendingId);

const { res: completeRes, json } = await jsonRequest(
env,
"/api/auth/email/complete",
{
method: "POST",
headers: { cookie: `email_pending=${pendingId}` },
body: { username: "zed\nsent you an Oy!" },
},
);

assert.equal(completeRes.status, 400);
assert.equal(
json.error,
"Username can only contain letters, numbers, and underscores",
);
assert.equal(db.users.length, 0);
});

it("links email for authenticated users", async (t) => {
const { env, kv, db } = createTestEnv();
const user = seedUser(db, { username: "Emailer" });
Expand Down
203 changes: 203 additions & 0 deletions tests/worker/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,4 +668,207 @@ describe("oauth", () => {
assert.equal(db.sessions.length, 1);
assert.equal(db.sessions[0].user_id, existing.id);
});

it("rejects malformed usernames during oauth completion", async () => {
const { env, kv, db } = createTestEnv();
await kv.put(
"oauth_pending:pending-hostile",
JSON.stringify({
provider: "google",
sub: "google-sub-hostile",
email: "hostile@example.com",
}),
);

const res = await request(env, "/api/auth/oauth/complete", {
method: "POST",
headers: {
"content-type": "application/json",
"x-oauth-pending": "pending-hostile",
},
body: JSON.stringify({
username: `<b>zed</b>\nOy! tap here 🎉${"a".repeat(300)}`,
}),
});

assert.equal(res.status, 400);
const body = (await res.json()) as { error: string };
assert.equal(body.error, "Username must be 2-20 characters");
assert.equal(db.users.length, 0);
assert.equal(db.sessions.length, 0);
});

it("rejects usernames with disallowed characters during oauth completion", async () => {
const { env, kv, db } = createTestEnv();
await kv.put(
"oauth_pending:pending-newline",
JSON.stringify({
provider: "google",
sub: "google-sub-newline",
email: "newline@example.com",
}),
);

const res = await request(env, "/api/auth/oauth/complete", {
method: "POST",
headers: {
"content-type": "application/json",
"x-oauth-pending": "pending-newline",
},
body: JSON.stringify({ username: "zed\nsent you an Oy!" }),
});

assert.equal(res.status, 400);
const body = (await res.json()) as { error: string };
assert.equal(
body.error,
"Username can only contain letters, numbers, and underscores",
);
assert.equal(db.users.length, 0);
assert.equal(db.sessions.length, 0);
});

it("rejects malformed usernames during native google sign-in", async (t) => {
const { env, db } = createTestEnv();

const originalFetch = globalThis.fetch;
globalThis.fetch = async (input) => {
const url = typeof input === "string" ? input : input.url;
if (url.startsWith("https://oauth2.googleapis.com/tokeninfo")) {
return {
ok: true,
json: async () => ({
aud: env.GOOGLE_CLIENT_ID,
sub: "google-native-hostile",
email: "native-hostile@example.com",
email_verified: "true",
}),
} as Response;
}
throw new Error(`Unexpected fetch: ${url}`);
};
t.after(() => {
globalThis.fetch = originalFetch;
});

const res = await request(env, "/api/auth/oauth/google/native", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
idToken: "native-id-token",
username: "zed\nsent you an Oy!",
}),
});

assert.equal(res.status, 400);
const body = (await res.json()) as { error: string };
assert.equal(
body.error,
"Username can only contain letters, numbers, and underscores",
);
assert.equal(db.users.length, 0);
assert.equal(db.sessions.length, 0);
});

it("rejects malformed usernames during native apple sign-in", async (t) => {
const { env, db } = createTestEnv();

const { publicKey, privateKey } = await crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true,
["sign", "verify"],
);
const jwk = (await crypto.subtle.exportKey("jwk", publicKey)) as JsonWebKey;
jwk.kid = "apple-test-kid-hostile";
const token = await createAppleIdToken({
privateKey,
sub: "apple-sub-hostile",
email: "apple-hostile@example.com",
aud: env.APPLE_NATIVE_CLIENT_ID ?? env.APPLE_CLIENT_ID,
kid: String(jwk.kid),
});

const originalFetch = globalThis.fetch;
globalThis.fetch = async (input) => {
const url = typeof input === "string" ? input : input.url;
if (url === "https://appleid.apple.com/auth/keys") {
return {
ok: true,
json: async () => ({ keys: [jwk] }),
} as Response;
}
throw new Error(`Unexpected fetch: ${url}`);
};
t.after(() => {
globalThis.fetch = originalFetch;
});

const res = await request(env, "/api/auth/oauth/apple/native", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
idToken: token,
username: `<b>zed</b>\nOy! tap here 🎉${"a".repeat(300)}`,
}),
});

assert.equal(res.status, 400);
const body = (await res.json()) as { error: string };
assert.equal(body.error, "Username must be 2-20 characters");
assert.equal(db.users.length, 0);
assert.equal(db.sessions.length, 0);
});

it("does not create users from malformed signup usernames in the google callback", async (t) => {
const { env, db } = createTestEnv();

const originalFetch = globalThis.fetch;
globalThis.fetch = async (input) => {
const url = typeof input === "string" ? input : input.url;
if (url === "https://oauth2.googleapis.com/token") {
return {
ok: true,
json: async () => ({ id_token: "token-callback-hostile" }),
} as Response;
}
if (url.startsWith("https://oauth2.googleapis.com/tokeninfo")) {
return {
ok: true,
json: async () => ({
aud: env.GOOGLE_CLIENT_ID,
sub: "google-callback-hostile",
email: "callback-hostile@example.com",
email_verified: "true",
}),
} as Response;
}
throw new Error(`Unexpected fetch: ${url}`);
};
t.after(() => {
globalThis.fetch = originalFetch;
});

const hostileUsername = "zed\nsent you an Oy!";
const startRes = await request(
env,
`/api/auth/oauth/google?username=${encodeURIComponent(hostileUsername)}`,
);
const startLocation = startRes.headers.get("location") ?? "";
const state = new URL(startLocation).searchParams.get("state") ?? "";

const res = await request(
env,
`/api/auth/oauth/callback?state=${state}&code=auth-code`,
);

assert.equal(res.status, 302);
assert.equal(res.headers.get("location"), "/?choose_username=1");
assert.equal(db.users.length, 0);
assert.equal(db.sessions.length, 0);
});
});
50 changes: 50 additions & 0 deletions tests/worker/username.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { normalizeUsername, validateUsername } from "../../worker/lib";

describe("username validation", () => {
it("normalizes to a trimmed, lowercased string", () => {
assert.equal(normalizeUsername(" Zed "), "zed");
assert.equal(normalizeUsername(undefined), "");
assert.equal(normalizeUsername(null), "");
assert.equal(normalizeUsername(12), "12");
});

it("accepts letters, numbers, and underscores", () => {
assert.equal(validateUsername("zed"), null);
assert.equal(validateUsername("Zed_99"), null);
assert.equal(validateUsername("a".repeat(20)), null);
});

it("rejects usernames outside the 2-20 character range", () => {
assert.equal(validateUsername(""), "Username must be 2-20 characters");
assert.equal(validateUsername("a"), "Username must be 2-20 characters");
assert.equal(
validateUsername("a".repeat(21)),
"Username must be 2-20 characters",
);
assert.equal(
validateUsername("a".repeat(300)),
"Username must be 2-20 characters",
);
});

it("rejects HTML, newlines, unicode, and whitespace", () => {
const badCharsError =
"Username can only contain letters, numbers, and underscores";
assert.equal(validateUsername("<b>zed</b>"), badCharsError);
assert.equal(validateUsername("zed\nOy from evil"), badCharsError);
assert.equal(validateUsername("zed‮gnihsihp"), badCharsError);
assert.equal(validateUsername("zed 🎉"), badCharsError);
assert.equal(validateUsername("zed user"), badCharsError);
assert.equal(validateUsername("zed-user"), badCharsError);
assert.equal(validateUsername("zed@example.com"), badCharsError);
});

it("rejects profanity", () => {
assert.equal(
validateUsername("shitname"),
"Username contains disallowed language",
);
});
});
36 changes: 22 additions & 14 deletions worker/lib.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { setCookie } from "hono/cookie";
import { validateCleanUsername } from "./moderation";
import { sendNativePushNotification, sendPushNotification } from "./push";
import type {
AppContext,
Expand Down Expand Up @@ -542,15 +543,32 @@ export function authUserPayload(user: User) {
};
}

const USERNAME_MIN_LENGTH = 2;
const USERNAME_MAX_LENGTH = 20;
const USERNAME_PATTERN = /^[a-zA-Z0-9_]+$/;

// Usernames are stored lowercase; every lookup compares on LOWER(username).
export function normalizeUsername(username: unknown) {
return String(username || "").trim();
return String(username ?? "")
.trim()
.toLowerCase();
}

// The single gate for every username that reaches the users table. Usernames
// are rendered as push notification copy, so anything but a short ASCII
// identifier is rejected here rather than at the advisory check endpoint.
export function validateUsername(username: string) {
if (!username || username.length < 2 || username.length > 20) {
return "Username must be 2-20 characters";
if (
!username ||
username.length < USERNAME_MIN_LENGTH ||
username.length > USERNAME_MAX_LENGTH
) {
return `Username must be ${USERNAME_MIN_LENGTH}-${USERNAME_MAX_LENGTH} characters`;
}
if (!USERNAME_PATTERN.test(username)) {
return "Username can only contain letters, numbers, and underscores";
}
return null;
return validateCleanUsername(username);
}

export async function createSession(c: AppContext, user: User) {
Expand Down Expand Up @@ -631,16 +649,6 @@ export async function fetchFriendsByOyRecency(
return friends.rows;
}

export async function fetchUserByUsername(
c: AppContext,
username: string,
): Promise<User | null> {
const result = await c
.get("db")
.query<User>("SELECT * FROM users WHERE username ILIKE $1", [username]);
return result.rows[0] ?? null;
}

export async function requireAdmin(c: AppContext) {
const user = c.get("user");
if (!user) {
Expand Down
Loading
Loading