From ac0d8d24e5326dbfd31b7f3e84cdddb9c9d4b098 Mon Sep 17 00:00:00 2001 From: SASAGAWA Kiyoshi Date: Wed, 14 Oct 2026 21:24:51 +0900 Subject: [PATCH] fix(security): block OAuth email takeover, gate OAuth sign-up, drop legacy register, guard remote URLs - OAuthService: never auto-link a provider login to an existing local account by email; refuse with "An account with this email already exists. Log in and link the provider from settings." Only verified provider emails are used (GitHub /user/emails verified, Google verified_email/email_verified, Discord verified); otherwise a placeholder email is stored. - OAuth sign-up now honours registration.enabled, invite-only / REQUIRE_INVITATION, blocked usernames and the user:beforeRegister plugin hook. Already-linked users can still log in. - routes/oauth: call requireAuth() (it was passed uncalled, so link/unlink/ accounts never worked). Add POST /:provider/link/authorize; the link state cookie is bound to the initiating session token and the callback links to that session's user. POST /:provider/link requires matching state. - unlinkAccount treats OAuth placeholder password hashes as no password. - Extract registration policy helpers to lib/registrationPolicy.ts (shared by /api/auth/register and OAuth). - Remove legacy POST /api/users, which bypassed all registration gating (unused by the frontend). - Frontend: add toSafeHttpUrl and use it for remote note.uri in NoteCardMenu (open in new tab / copy / share) and MFM url/link nodes. Claude-Session: https://claude.ai/code/session_01Y29fBeRP344FME6de48wUv --- .../backend/src/lib/registrationPolicy.ts | 82 ++++ packages/backend/src/lib/validation.ts | 2 +- packages/backend/src/routes/auth.ts | 31 +- packages/backend/src/routes/oauth.ts | 215 +++++++-- packages/backend/src/routes/users.ts | 111 +---- packages/backend/src/services/OAuthService.ts | 450 +++++++++++++----- .../tests/unit/OAuthRoutesSecurity.test.ts | 333 +++++++++++++ .../src/tests/unit/OAuthService.test.ts | 292 +++++++++++- .../src/components/mfm/MfmRenderer.tsx | 20 +- .../src/components/note/NoteCardMenu.tsx | 9 +- packages/frontend/src/lib/utils/safeUrl.ts | 31 ++ packages/frontend/src/tests/safeUrl.test.ts | 38 ++ 12 files changed, 1323 insertions(+), 291 deletions(-) create mode 100644 packages/backend/src/lib/registrationPolicy.ts create mode 100644 packages/backend/src/tests/unit/OAuthRoutesSecurity.test.ts create mode 100644 packages/frontend/src/lib/utils/safeUrl.ts create mode 100644 packages/frontend/src/tests/safeUrl.test.ts diff --git a/packages/backend/src/lib/registrationPolicy.ts b/packages/backend/src/lib/registrationPolicy.ts new file mode 100644 index 00000000..854f6134 --- /dev/null +++ b/packages/backend/src/lib/registrationPolicy.ts @@ -0,0 +1,82 @@ +/** + * Registration Policy + * + * Single source of truth for whether new local accounts may be created, shared by + * password sign-up (`/api/auth/register`) and OAuth sign-up. + * + * @module lib/registrationPolicy + */ + +import type { InstanceSettingsService } from "../services/InstanceSettingsService.js"; + +/** + * Subset of InstanceSettingsService needed to evaluate the registration policy. + */ +export type RegistrationSettingsSource = Pick< + InstanceSettingsService, + "isRegistrationEnabled" | "isInviteOnly" +>; + +/** + * Effective registration policy for the instance. + */ +export interface RegistrationPolicy { + /** Whether new accounts may be created at all */ + registrationEnabled: boolean; + /** Whether new accounts require an invitation code */ + inviteOnly: boolean; +} + +/** + * Check if registration requires an invitation code. + * + * Uses the DB setting first, falling back to the `REQUIRE_INVITATION` env var. + * + * @param settings - Instance settings source + * @returns True if invite-only mode is active + */ +export async function isInviteOnly(settings: RegistrationSettingsSource): Promise { + if (await settings.isInviteOnly()) return true; + + // Fall back to environment variable for backwards compatibility + const requireInvitation = process.env.REQUIRE_INVITATION; + return requireInvitation === "true" || requireInvitation === "1"; +} + +/** + * Check if registration is enabled. + * + * Uses the DB setting first; if it is false, `ENABLE_REGISTRATION=false|0` + * keeps it disabled. + * + * @param settings - Instance settings source + * @returns True if registration is enabled + */ +export async function isRegistrationEnabled( + settings: RegistrationSettingsSource, +): Promise { + const dbSetting = await settings.isRegistrationEnabled(); + + if (!dbSetting) { + const envSetting = process.env.ENABLE_REGISTRATION; + if (envSetting === "false" || envSetting === "0") return false; + } + + return dbSetting; +} + +/** + * Resolve the full registration policy. + * + * @param settings - Instance settings source + * @returns Effective registration policy + */ +export async function getRegistrationPolicy( + settings: RegistrationSettingsSource, +): Promise { + const [registrationEnabled, inviteOnly] = await Promise.all([ + isRegistrationEnabled(settings), + isInviteOnly(settings), + ]); + return { registrationEnabled, inviteOnly }; +} diff --git a/packages/backend/src/lib/validation.ts b/packages/backend/src/lib/validation.ts index b55becb1..d5147ff8 100644 --- a/packages/backend/src/lib/validation.ts +++ b/packages/backend/src/lib/validation.ts @@ -192,7 +192,7 @@ export const repliesQuerySchema = z.object({ // ============================================ /** - * POST /api/users - User registration (same as auth register) + * User registration schema (alias of registerSchema; the legacy POST /api/users endpoint was removed) */ export const createUserSchema = registerSchema; diff --git a/packages/backend/src/routes/auth.ts b/packages/backend/src/routes/auth.ts index c112e0ef..b0083119 100644 --- a/packages/backend/src/routes/auth.ts +++ b/packages/backend/src/routes/auth.ts @@ -12,6 +12,7 @@ import { PasskeyService } from "../services/PasskeyService.js"; import { requireAuth } from "../middleware/auth.js"; import { rateLimit, RateLimitPresets } from "../middleware/rateLimit.js"; import { logger } from "../lib/logger.js"; +import { isInviteOnly, isRegistrationEnabled } from "../lib/registrationPolicy.js"; import type { Context } from "hono"; const app = new Hono(); @@ -20,37 +21,15 @@ const app = new Hono(); * Check if registration requires an invitation code * Uses DB setting first, falls back to environment variable */ -const checkIsInviteOnly = async (c: Context): Promise => { - // First check DB setting - const instanceSettingsService = c.get("instanceSettingsService"); - const dbSetting = await instanceSettingsService.isInviteOnly(); - - // If DB says invite-only, use that - if (dbSetting) return true; - - // Fall back to environment variable for backwards compatibility - const requireInvitation = process.env.REQUIRE_INVITATION; - return requireInvitation === "true" || requireInvitation === "1"; -}; +const checkIsInviteOnly = (c: Context): Promise => + isInviteOnly(c.get("instanceSettingsService")); /** * Check if registration is enabled * Uses DB setting first, falls back to environment variable */ -const checkIsRegistrationEnabled = async (c: Context): Promise => { - const instanceSettingsService = c.get("instanceSettingsService"); - const dbSetting = await instanceSettingsService.isRegistrationEnabled(); - - // If DB setting exists and is false, registration is disabled - // Fall back to env var ENABLE_REGISTRATION if DB doesn't have a setting - if (!dbSetting) { - const envSetting = process.env.ENABLE_REGISTRATION; - // If env var is explicitly 'false' or '0', registration is disabled - if (envSetting === "false" || envSetting === "0") return false; - } - - return dbSetting; -}; +const checkIsRegistrationEnabled = (c: Context): Promise => + isRegistrationEnabled(c.get("instanceSettingsService")); /** * Registration Settings diff --git a/packages/backend/src/routes/oauth.ts b/packages/backend/src/routes/oauth.ts index dfb9c5cb..47e70efd 100644 --- a/packages/backend/src/routes/oauth.ts +++ b/packages/backend/src/routes/oauth.ts @@ -9,18 +9,23 @@ import { Hono } from "hono"; import { setCookie, getCookie } from "hono/cookie"; -import { OAuthService } from "../services/OAuthService.js"; +import { OAuthService, OAuthError } from "../services/OAuthService.js"; +import { AuthService } from "../services/AuthService.js"; import { requireAuth } from "../middleware/auth.js"; import { rateLimit, RateLimitPresets } from "../middleware/rateLimit.js"; import { logger } from "../lib/logger.js"; +import { getRegistrationPolicy } from "../lib/registrationPolicy.js"; import { generateId } from "shared"; import type { Context } from "hono"; import type { OAuthProvider } from "../interfaces/repositories/IOAuthAccountRepository.js"; const app = new Hono(); -// State cookie name for CSRF protection +// State cookie name for CSRF protection (login / sign-up flow) const STATE_COOKIE_NAME = "oauth_state"; +// State cookie for the account-linking flow. Its value binds the state to the +// initiating session token, so it cannot be forged for another user. +const LINK_STATE_COOKIE_NAME = "oauth_link_state"; const STATE_COOKIE_MAX_AGE = 600; // 10 minutes /** @@ -33,7 +38,13 @@ const getOAuthService = (c: Context): OAuthService => { const oauthAccountRepository = c.get("oauthAccountRepository"); const userRepository = c.get("userRepository"); const sessionRepository = c.get("sessionRepository"); - return new OAuthService(oauthAccountRepository, userRepository, sessionRepository); + return new OAuthService( + oauthAccountRepository, + userRepository, + sessionRepository, + c.get("blockedUsernameService"), + c.get("eventBus"), + ); }; /** @@ -46,6 +57,59 @@ const isValidProvider = (provider: string): provider is OAuthProvider => { return ["github", "google", "discord", "mastodon"].includes(provider); }; +/** + * Set (or clear, when maxAge is 0) a short-lived httpOnly OAuth state cookie. + * + * @param c - Hono request context + * @param name - Cookie name + * @param value - Cookie value + * @param maxAge - Lifetime in seconds + */ +const setStateCookie = (c: Context, name: string, value: string, maxAge: number): void => { + setCookie(c, name, value, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "Lax", + maxAge, + path: "/", + }); +}; + +/** + * Build the link-state cookie value binding a state to a session token. + * + * @param state - Random OAuth state + * @param sessionToken - Session token of the user who initiated linking + * @returns Cookie value + */ +const buildLinkStateValue = (state: string, sessionToken: string): string => + `${state}.${sessionToken}`; + +/** + * Parse a link-state cookie value. + * + * @param value - Cookie value + * @returns State and session token, or null if malformed + */ +const parseLinkStateValue = ( + value: string | undefined, +): { state: string; sessionToken: string } | null => { + if (!value) return null; + const idx = value.indexOf("."); + if (idx <= 0 || idx === value.length - 1) return null; + return { state: value.substring(0, idx), sessionToken: value.substring(idx + 1) }; +}; + +/** + * Get a user-safe message for an OAuth failure. + * + * @param error - Caught error + * @param fallback - Message to use for unexpected errors + * @returns Message safe to show to end users + */ +const toUserMessage = (error: unknown, fallback: string): string => + error instanceof OAuthError ? error.message : fallback; + /** * Get enabled OAuth providers * @@ -89,15 +153,9 @@ app.get("/:provider/authorize", rateLimit(RateLimitPresets.login), async (c) => // Generate state for CSRF protection const state = generateId(); - - // Store state in cookie - setCookie(c, STATE_COOKIE_NAME, state, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "Lax", - maxAge: STATE_COOKIE_MAX_AGE, - path: "/", - }); + setStateCookie(c, STATE_COOKIE_NAME, state, STATE_COOKIE_MAX_AGE); + // A plain login must never be mistaken for a pending link flow + setStateCookie(c, LINK_STATE_COOKIE_NAME, "", 0); // Get authorization URL const authUrl = oauthService.getAuthorizationUrl(provider, state); @@ -113,13 +171,55 @@ app.get("/:provider/authorize", rateLimit(RateLimitPresets.login), async (c) => } }); +/** + * Initiate OAuth account linking for the authenticated user + * + * POST /api/auth/oauth/:provider/link/authorize + * + * Returns the provider authorization URL (the client navigates to it) and sets an + * httpOnly state cookie bound to the caller's session token. When the provider + * redirects back to the callback, the provider account is linked to that session's + * user instead of performing a login. Requires authentication. + */ +app.post( + "/:provider/link/authorize", + requireAuth(), + rateLimit(RateLimitPresets.login), + async (c) => { + const provider = c.req.param("provider")!; + const session = c.get("session")!; + + if (!isValidProvider(provider)) { + return c.json({ error: "Invalid OAuth provider" }, 400); + } + + const oauthService = getOAuthService(c); + if (!oauthService.isProviderEnabled(provider)) { + return c.json({ error: `${provider} OAuth is not configured` }, 400); + } + + const state = generateId(); + setStateCookie( + c, + LINK_STATE_COOKIE_NAME, + buildLinkStateValue(state, session.token), + STATE_COOKIE_MAX_AGE, + ); + // Ensure a stale login state can't be combined with this flow + setStateCookie(c, STATE_COOKIE_NAME, "", 0); + + return c.json({ url: oauthService.getAuthorizationUrl(provider, state), state }); + }, +); + /** * OAuth callback handler * * GET /api/auth/oauth/:provider/callback * * Handles the OAuth callback from the provider. - * Verifies state, exchanges code for tokens, and creates/links user. + * Verifies state, exchanges code for tokens, then either links the provider to the + * user who initiated a link flow, or logs in / signs up. */ app.get("/:provider/callback", rateLimit(RateLimitPresets.login), async (c) => { const provider = c.req.param("provider")!; @@ -142,30 +242,49 @@ app.get("/:provider/callback", rateLimit(RateLimitPresets.login), async (c) => { return c.redirect("/login?error=Missing+OAuth+parameters"); } - try { - // Verify state against cookie - const storedState = getCookie(c, STATE_COOKIE_NAME); - if (!storedState || storedState !== state) { - logger.warn({ provider }, "OAuth state mismatch"); - return c.redirect("/login?error=Invalid+OAuth+state"); + const storedState = getCookie(c, STATE_COOKIE_NAME); + const linkState = parseLinkStateValue(getCookie(c, LINK_STATE_COOKIE_NAME)); + + // Clear both state cookies (single use) + setStateCookie(c, STATE_COOKIE_NAME, "", 0); + setStateCookie(c, LINK_STATE_COOKIE_NAME, "", 0); + + // ---- Link flow (state bound to an authenticated session) ---- + if (linkState && linkState.state === state) { + try { + const authService = new AuthService(c.get("userRepository"), c.get("sessionRepository")); + const auth = await authService.validateSession(linkState.sessionToken); + if (!auth) { + return c.redirect("/login?error=Session+expired.+Log+in+and+try+linking+again."); + } + + const oauthService = getOAuthService(c); + const { tokens, profile } = await oauthService.handleCallback(provider, code); + await oauthService.linkProfile(auth.user.id, provider, profile, tokens); + + return c.redirect(`/settings?oauth_linked=${encodeURIComponent(provider)}`); + } catch (err) { + logger.error({ err, provider }, "OAuth link callback error"); + const message = toUserMessage(err, "Failed to link OAuth account"); + return c.redirect(`/settings?error=${encodeURIComponent(message)}`); } + } - // Clear state cookie - setCookie(c, STATE_COOKIE_NAME, "", { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "Lax", - maxAge: 0, - path: "/", - }); + // ---- Login / sign-up flow ---- + if (!storedState || storedState !== state) { + logger.warn({ provider }, "OAuth state mismatch"); + return c.redirect("/login?error=Invalid+OAuth+state"); + } + try { const oauthService = getOAuthService(c); // Exchange code for tokens and get user profile const { tokens, profile } = await oauthService.handleCallback(provider, code); - // Find or create user - const { user, isNew } = await oauthService.findOrCreateUser(provider, profile, tokens); + // Find existing linked user, or create one subject to registration policy + const policy = await getRegistrationPolicy(c.get("instanceSettingsService")); + const { user, isNew } = await oauthService.findOrCreateUser(provider, profile, tokens, policy); // Check if user is suspended if (user.isSuspended) { @@ -198,35 +317,54 @@ app.get("/:provider/callback", rateLimit(RateLimitPresets.login), async (c) => { const redirectUrl = isNew ? "/settings/profile" : "/"; return c.redirect(redirectUrl); } catch (error) { - logger.error({ err: error, provider }, "OAuth callback error"); + if (error instanceof OAuthError) { + logger.warn({ provider, code: error.code }, "OAuth login refused"); + } else { + logger.error({ err: error, provider }, "OAuth callback error"); + } const message = error instanceof Error ? error.message : "OAuth authentication failed"; return c.redirect(`/login?error=${encodeURIComponent(message)}`); } }); /** - * Link OAuth account to existing user + * Link OAuth account to existing user (code exchange from a client-side callback) * * POST /api/auth/oauth/:provider/link * * Links an OAuth provider account to the currently authenticated user. + * The `state` must match the httpOnly link-state cookie issued by + * `POST /:provider/link/authorize` for the same session. * Requires authentication. */ -app.post("/:provider/link", requireAuth, rateLimit(RateLimitPresets.login), async (c) => { +app.post("/:provider/link", requireAuth(), rateLimit(RateLimitPresets.login), async (c) => { const provider = c.req.param("provider")!; const user = c.get("user")!; + const session = c.get("session")!; if (!isValidProvider(provider)) { return c.json({ error: "Invalid OAuth provider" }, 400); } try { - const body = await c.req.json<{ code: string }>(); + const body = await c.req.json<{ code?: string; state?: string }>(); - if (!body.code) { + if (!body.code || typeof body.code !== "string") { return c.json({ error: "Missing authorization code" }, 400); } + const linkState = parseLinkStateValue(getCookie(c, LINK_STATE_COOKIE_NAME)); + setStateCookie(c, LINK_STATE_COOKIE_NAME, "", 0); + if ( + !linkState || + typeof body.state !== "string" || + linkState.state !== body.state || + linkState.sessionToken !== session.token + ) { + logger.warn({ provider, userId: user.id }, "OAuth link state mismatch"); + return c.json({ error: "Invalid OAuth state" }, 400); + } + const oauthService = getOAuthService(c); const oauthAccount = await oauthService.linkAccount(user.id, provider, body.code); @@ -242,10 +380,7 @@ app.post("/:provider/link", requireAuth, rateLimit(RateLimitPresets.login), asyn }); } catch (error) { logger.error({ err: error, provider, userId: user.id }, "OAuth link error"); - return c.json( - { error: error instanceof Error ? error.message : "Failed to link OAuth account" }, - 400, - ); + return c.json({ error: toUserMessage(error, "Failed to link OAuth account") }, 400); } }); @@ -257,7 +392,7 @@ app.post("/:provider/link", requireAuth, rateLimit(RateLimitPresets.login), asyn * Removes an OAuth provider link from the authenticated user. * Requires authentication. */ -app.delete("/:provider", requireAuth, rateLimit(RateLimitPresets.login), async (c) => { +app.delete("/:provider", requireAuth(), rateLimit(RateLimitPresets.login), async (c) => { const provider = c.req.param("provider")!; const user = c.get("user")!; @@ -289,7 +424,7 @@ app.delete("/:provider", requireAuth, rateLimit(RateLimitPresets.login), async ( * Returns list of OAuth providers linked to the authenticated user. * Requires authentication. */ -app.get("/accounts", requireAuth, async (c) => { +app.get("/accounts", requireAuth(), async (c) => { const user = c.get("user")!; try { diff --git a/packages/backend/src/routes/users.ts b/packages/backend/src/routes/users.ts index 7fc8786d..be18cad5 100644 --- a/packages/backend/src/routes/users.ts +++ b/packages/backend/src/routes/users.ts @@ -1,13 +1,14 @@ /** * User API Routes * - * Provides endpoints for user registration, and retrieving and updating user information. + * Provides endpoints for retrieving and updating user information. + * Registration lives at `POST /api/auth/register` (the legacy `POST /api/users` + * endpoint was removed because it bypassed registration gating). * * @module routes/users */ import { Hono } from "hono"; -import { AuthService } from "../services/AuthService.js"; import { UserService } from "../services/UserService.js"; import { requireAuth, optionalAuth } from "../middleware/auth.js"; import { errorResponse } from "../lib/routeUtils.js"; @@ -15,112 +16,6 @@ import { verifyPassword } from "../utils/password.js"; const app = new Hono(); -/** - * User Registration - * - * POST /api/users - * - * Registers a new user, automatically logs in, and issues a session token. - * Only enabled when ENABLE_REGISTRATION environment variable is true. - * - * @remarks - * Request Body: - * ```json - * { - * "username": "alice", - * "email": "alice@example.com", - * "password": "securePassword123", - * "name": "Alice Smith" // optional - * } - * ``` - * - * Validation: - * - username: 3-20 characters, alphanumeric and underscores only - * - email: Valid email address format - * - password: Minimum 8 characters - * - * Response (201): - * ```json - * { - * "user": { "id": "...", "username": "alice", ... }, - * "token": "a3f2e1d0c9b8a7f6..." - * } - * ``` - * - * Errors: - * - 400: Validation error - * - 403: Registration disabled - * - 409: Username or email already exists - */ -app.post("/", async (c) => { - // 登録機能が無効な場合 - if (process.env.ENABLE_REGISTRATION !== "true") { - return c.json({ error: "Registration is disabled" }, 403); - } - - const body = await c.req.json(); - - // バリデーション - if (!body.username || typeof body.username !== "string") { - return c.json({ error: "Username is required" }, 400); - } - if (!body.email || typeof body.email !== "string") { - return c.json({ error: "Email is required" }, 400); - } - if (!body.password || typeof body.password !== "string") { - return c.json({ error: "Password is required" }, 400); - } - - // ユーザー名のバリデーション(英数字とアンダースコアのみ、3-20文字) - if (!/^[a-zA-Z0-9_]{3,20}$/.test(body.username)) { - return c.json( - { - error: - "Username must be 3-20 characters and contain only letters, numbers, and underscores", - }, - 400, - ); - } - - // メールアドレスの簡易バリデーション - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) { - return c.json({ error: "Invalid email address" }, 400); - } - - // パスワードの長さチェック(最低8文字) - if (body.password.length < 8) { - return c.json({ error: "Password must be at least 8 characters" }, 400); - } - - try { - const authService = new AuthService(c.get("userRepository"), c.get("sessionRepository")); - const { user, session } = await authService.register({ - username: body.username, - email: body.email, - password: body.password, - name: body.name, - }); - - // パスワードハッシュを除外してレスポンス - const { passwordHash: _passwordHash, email: _email, ...publicUser } = user; - - return c.json( - { - user: publicUser, - token: session.token, - }, - 201, - ); - } catch (error) { - if (error instanceof Error) { - if (error.message.includes("already exists")) { - return c.json({ error: error.message }, 409); - } - } - throw error; - } -}); - /** * Get Own User Information * diff --git a/packages/backend/src/services/OAuthService.ts b/packages/backend/src/services/OAuthService.ts index e7784aa8..d2856fb6 100644 --- a/packages/backend/src/services/OAuthService.ts +++ b/packages/backend/src/services/OAuthService.ts @@ -17,6 +17,96 @@ import type { ISessionRepository } from "../interfaces/repositories/ISessionRepo import type { User, OAuthAccount } from "../db/schema/pg.js"; import { logger } from "../lib/logger.js"; import { generateKeyPair } from "../utils/crypto.js"; +import type { BlockedUsernameService } from "./BlockedUsernameService.js"; +import type { EventBus } from "../lib/events.js"; +import { toPluginUser } from "../plugins/utils.js"; +import type { RegistrationPolicy } from "../lib/registrationPolicy.js"; + +/** + * Prefix of the placeholder password hash given to OAuth-created users. + * Such hashes can never match a password. + */ +const OAUTH_PASSWORD_HASH_PREFIX = "oauth:"; + +/** + * Registration policy applied when an OAuth login would create a new account. + */ +export type OAuthRegistrationPolicy = RegistrationPolicy; + +/** + * Error codes raised by OAuthService for user-facing failures. + */ +export type OAuthErrorCode = + | "email_exists" + | "registration_disabled" + | "invite_only" + | "registration_cancelled" + | "username_not_allowed" + | "already_linked" + | "linked_to_other_user"; + +/** + * User-facing OAuth error. The message is safe to show to end users. + */ +export class OAuthError extends Error { + /** + * @param code - Machine-readable error code + * @param message - Human-readable message safe to display + */ + constructor( + public readonly code: OAuthErrorCode, + message: string, + ) { + super(message); + this.name = "OAuthError"; + } +} + +/** + * Whether a stored password hash can actually be used for password login. + * + * @param passwordHash - Stored password hash (may be an OAuth placeholder) + * @returns False for empty hashes and OAuth placeholder hashes + */ +export function hasUsablePassword(passwordHash: string | null | undefined): boolean { + return !!passwordHash && !passwordHash.startsWith(OAUTH_PASSWORD_HASH_PREFIX); +} + +/** + * Select a verified email from GitHub's `/user/emails` response. + * + * Prefers the public profile email when it is listed as verified, otherwise the + * primary verified email. Unverified addresses are never returned. + * + * @param publicEmail - Email from the public profile (carries no verification flag) + * @param emails - Entries from `GET /user/emails` + * @returns A verified email or null + */ +export function selectVerifiedGitHubEmail( + publicEmail: string | null, + emails: GitHubEmail[], +): string | null { + if (publicEmail) { + const match = emails.find( + (e) => e.verified && e.email.toLowerCase() === publicEmail.toLowerCase(), + ); + if (match) return match.email; + } + return emails.find((e) => e.primary && e.verified)?.email ?? null; +} + +/** + * Whether a Google userinfo response reports the email as verified. + * + * @param user - Google userinfo response (v2 or OIDC shape) + * @returns True only when Google explicitly marks the email as verified + */ +export function isGoogleEmailVerified(user: { + verified_email?: boolean; + email_verified?: boolean; +}): boolean { + return user.verified_email === true || user.email_verified === true; +} /** * GitHub user profile response @@ -32,7 +122,7 @@ interface GitHubUser { /** * GitHub email response */ -interface GitHubEmail { +export interface GitHubEmail { email: string; primary: boolean; verified: boolean; @@ -44,6 +134,10 @@ interface GitHubEmail { interface GoogleUser { id: string; email?: string; + /** Whether Google has verified the email address (v2 userinfo) */ + verified_email?: boolean; + /** Whether Google has verified the email address (OIDC userinfo) */ + email_verified?: boolean; name?: string; picture?: string; } @@ -56,6 +150,8 @@ interface DiscordUser { username: string; global_name?: string; email?: string; + /** Whether the email on the Discord account has been verified */ + verified?: boolean; avatar?: string; } @@ -105,6 +201,10 @@ interface OAuthTokenResponse { export interface OAuthUserProfile { id: string; username: string; + /** + * Email address reported by the provider. + * Only set when the provider has verified the address; otherwise null. + */ email: string | null; displayName: string | null; avatarUrl: string | null; @@ -120,6 +220,8 @@ export class OAuthService { private oauthAccountRepository: IOAuthAccountRepository, private userRepository: IUserRepository, private sessionRepository: ISessionRepository, + private blockedUsernameService?: Pick, + private eventBus?: EventBus, ) { // Load configs from environment this.loadConfigs(); @@ -456,21 +558,19 @@ export class OAuthService { const user = (await userResponse.json()) as GitHubUser; - // Get primary email if not public - let email: string | null = user.email; - if (!email) { - const emailResponse = await fetch("https://api.github.com/user/emails", { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/vnd.github.v3+json", - }, - }); + // Only trust emails that GitHub reports as verified. The public profile + // email carries no verification flag, so always consult /user/emails. + let email: string | null = null; + const emailResponse = await fetch("https://api.github.com/user/emails", { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github.v3+json", + }, + }); - if (emailResponse.ok) { - const emails = (await emailResponse.json()) as GitHubEmail[]; - const primaryEmail = emails.find((e) => e.primary && e.verified); - email = primaryEmail?.email || null; - } + if (emailResponse.ok) { + const emails = (await emailResponse.json()) as GitHubEmail[]; + email = selectVerifiedGitHubEmail(user.email, emails); } return { @@ -498,7 +598,7 @@ export class OAuthService { return { id: user.id, username: user.email?.split("@")[0] || user.id, - email: user.email || null, + email: isGoogleEmailVerified(user) ? user.email || null : null, displayName: user.name || null, avatarUrl: user.picture || null, }; @@ -527,7 +627,7 @@ export class OAuthService { return { id: user.id, username: user.username, - email: user.email || null, + email: user.verified === true ? user.email || null : null, displayName: user.global_name || user.username, avatarUrl, }; @@ -563,13 +663,28 @@ export class OAuthService { } /** - * Find or create user from OAuth profile - * Returns the user and a flag indicating if it's a new user + * Find or create a user from an OAuth profile. + * + * Security rules: + * - A provider identity is only ever resolved through an existing OAuth link row. + * - It is never auto-linked to an existing local account by email. Rox does not + * verify local emails, and some providers report unverified emails, so an email + * match is not proof of ownership. Users must log in and link explicitly. + * - New accounts are subject to the same registration gating as password sign-up + * (registration enabled, invite-only mode, blocked usernames, plugin veto). + * + * @param provider - OAuth provider + * @param profile - Normalized provider profile (email is only set when verified) + * @param tokens - Provider token response + * @param policy - Current instance registration policy + * @returns The user, whether it was newly created, and the OAuth account link + * @throws {OAuthError} When an account with the same email exists, or sign-up is not allowed */ async findOrCreateUser( provider: OAuthProvider, profile: OAuthUserProfile, tokens: OAuthTokenResponse, + policy: OAuthRegistrationPolicy, ): Promise<{ user: User; isNew: boolean; oauthAccount: OAuthAccount }> { // Check if OAuth account already exists const existingOAuthAccount = await this.oauthAccountRepository.findByProviderAccount( @@ -600,86 +715,183 @@ export class OAuthService { return { user, isNew: false, oauthAccount: updatedAccount }; } - // Check if user with same email exists (to link accounts) - let user: User | null = null; - let isNew = false; - + // Never auto-link by email: refuse if a local account already uses this email. if (profile.email) { - user = await this.userRepository.findByEmail(profile.email); + const emailOwner = await this.userRepository.findByEmail(profile.email); + if (emailOwner) { + logger.warn( + { provider, userId: emailOwner.id }, + "OAuth login refused: email already belongs to an existing account", + ); + throw new OAuthError( + "email_exists", + "An account with this email already exists. Log in and link the provider from settings.", + ); + } } - if (!user) { - // Create new user - const baseUsername = this.sanitizeUsername(profile.username); + // New account: apply registration gating + if (!policy.registrationEnabled) { + throw new OAuthError("registration_disabled", "Registration is currently disabled"); + } + if (policy.inviteOnly) { + throw new OAuthError( + "invite_only", + "Registration on this server requires an invitation code. Sign up with an invitation code first, then link the provider from settings.", + ); + } + + const username = await this.pickAvailableUsername(profile.username); + + // Let plugins veto or adjust the registration (same hook as password sign-up) + let displayName = profile.displayName; + if (this.eventBus) { + const beforeResult = await this.eventBus.emitBefore("user:beforeRegister", { + username, + displayName, + }); + if (beforeResult.cancelled) { + throw new OAuthError( + "registration_cancelled", + beforeResult.cancelReason || "Registration cancelled by plugin", + ); + } + if (beforeResult.modifiedPayload) { + displayName = beforeResult.modifiedPayload.displayName ?? displayName; + } + } + + // Generate ActivityPub keypair for OAuth users + const { publicKey, privateKey } = generateKeyPair(); + const baseUrl = process.env.URL || "http://localhost:3000"; + + const user = await this.userRepository.create({ + id: generateId(), + username, + // Only verified provider emails are stored; otherwise use a non-routable placeholder + // (the column is NOT NULL + UNIQUE). + email: profile.email || `${profile.id}@${provider}.oauth`, + // OAuth-only users get a random unusable password hash + // This hash is not a valid bcrypt hash, so password login will always fail + passwordHash: `${OAUTH_PASSWORD_HASH_PREFIX}${provider}:${generateId()}`, + displayName, + host: null, // Local user + avatarUrl: profile.avatarUrl, + bannerUrl: null, + bio: null, + isAdmin: false, + isSuspended: false, + isDeleted: false, + deletedAt: null, + isSystemUser: false, + publicKey, + privateKey, + customCss: null, + uiSettings: null, + // ActivityPub fields for local users + inbox: `${baseUrl}/users/${username}/inbox`, + outbox: `${baseUrl}/users/${username}/outbox`, + followersUrl: `${baseUrl}/users/${username}/followers`, + followingUrl: `${baseUrl}/users/${username}/following`, + uri: `${baseUrl}/users/${username}`, + sharedInbox: null, + // Account migration fields + alsoKnownAs: [], + movedTo: null, + movedAt: null, + // Profile emojis + profileEmojis: [], + // Storage quota + storageQuotaMb: null, + // Remote actor fetch status (not applicable to local users) + goneDetectedAt: null, + fetchFailureCount: 0, + lastFetchAttemptAt: null, + lastFetchError: null, + // Follower/following counts start at 0 + followersCount: 0, + followingCount: 0, + }); + + logger.info({ provider, userId: user.id, username }, "OAuth login: new user created"); + + if (this.eventBus) { + this.eventBus.emit("user:afterRegister", { user: toPluginUser(user) }).catch((error) => { + logger.error({ err: error, userId: user.id }, "Failed to emit user:afterRegister event"); + }); + } + + const oauthAccount = await this.createLink(user.id, provider, profile, tokens); + + return { user, isNew: true, oauthAccount }; + } + + /** + * Pick a unique, non-blocked username derived from the provider username. + * + * @param providerUsername - Username reported by the provider + * @returns An available username + * @throws {OAuthError} When no allowed username could be derived + */ + private async pickAvailableUsername(providerUsername: string): Promise { + // Fallback base (random) is used if the derived name is blocked (e.g. reserved words) + const candidates = [ + this.sanitizeUsername(providerUsername), + this.sanitizeUsername(`user${generateId().substring(0, 8)}`), + ]; + + for (const baseUsername of candidates) { + if (await this.isUsernameBlocked(baseUsername)) continue; + let username = baseUsername; let attempt = 0; - - // Ensure unique username while (await this.userRepository.findByUsername(username)) { attempt++; - username = `${baseUsername}${attempt}`; + const suffix = String(attempt); + username = `${baseUsername.substring(0, 20 - suffix.length)}${suffix}`; } - // Generate ActivityPub keypair for OAuth users - const { publicKey, privateKey } = generateKeyPair(); - const baseUrl = process.env.URL || "http://localhost:3000"; + if (username === baseUsername || !(await this.isUsernameBlocked(username))) { + return username; + } + } - user = await this.userRepository.create({ - id: generateId(), - username, - email: profile.email || `${profile.id}@${provider}.oauth`, - // OAuth-only users get a random unusable password hash - // This hash is not a valid bcrypt hash, so password login will always fail - passwordHash: `oauth:${provider}:${generateId()}`, - displayName: profile.displayName, - host: null, // Local user - avatarUrl: profile.avatarUrl, - bannerUrl: null, - bio: null, - isAdmin: false, - isSuspended: false, - isDeleted: false, - deletedAt: null, - isSystemUser: false, - publicKey, - privateKey, - customCss: null, - uiSettings: null, - // ActivityPub fields for local users - inbox: `${baseUrl}/users/${username}/inbox`, - outbox: `${baseUrl}/users/${username}/outbox`, - followersUrl: `${baseUrl}/users/${username}/followers`, - followingUrl: `${baseUrl}/users/${username}/following`, - uri: `${baseUrl}/users/${username}`, - sharedInbox: null, - // Account migration fields - alsoKnownAs: [], - movedTo: null, - movedAt: null, - // Profile emojis - profileEmojis: [], - // Storage quota - storageQuotaMb: null, - // Remote actor fetch status (not applicable to local users) - goneDetectedAt: null, - fetchFailureCount: 0, - lastFetchAttemptAt: null, - lastFetchError: null, - // Follower/following counts start at 0 - followersCount: 0, - followingCount: 0, - }); + throw new OAuthError( + "username_not_allowed", + "Could not derive an allowed username from your account. Please register with a password instead.", + ); + } - isNew = true; - logger.info({ provider, userId: user.id, username }, "OAuth login: new user created"); - } else { - logger.info({ provider, userId: user.id }, "OAuth login: linked to existing user by email"); - } + /** + * Check a username against the blocked/reserved username list. + * + * @param username - Candidate username + * @returns True if the username must not be used + */ + private async isUsernameBlocked(username: string): Promise { + if (!this.blockedUsernameService) return false; + const result = await this.blockedUsernameService.isUsernameBlocked(username); + return result.blocked; + } - // Create OAuth account link - const oauthAccount = await this.oauthAccountRepository.create({ + /** + * Persist an OAuth link row for a user. + * + * @param userId - Local user ID + * @param provider - OAuth provider + * @param profile - Provider profile + * @param tokens - Provider token response + * @returns The created OAuth account link + */ + private createLink( + userId: string, + provider: OAuthProvider, + profile: OAuthUserProfile, + tokens: OAuthTokenResponse, + ): Promise { + return this.oauthAccountRepository.create({ id: generateId(), - userId: user.id, + userId, provider, providerAccountId: profile.id, accessToken: tokens.access_token, @@ -690,8 +902,6 @@ export class OAuthService { providerUsername: profile.username, providerEmail: profile.email, }); - - return { user, isNew, oauthAccount }; } /** @@ -719,38 +929,61 @@ export class OAuthService { } /** - * Link OAuth account to existing user + * Link an OAuth account to an existing (authenticated) user by exchanging a code. + * + * @param userId - ID of the authenticated user + * @param provider - OAuth provider + * @param code - Authorization code returned by the provider + * @returns The created OAuth account link */ async linkAccount(userId: string, provider: OAuthProvider, code: string): Promise { - // Check if already linked + // Check if already linked (before spending the code) const existing = await this.oauthAccountRepository.findByUserAndProvider(userId, provider); if (existing) { - throw new Error(`${provider} account is already linked`); + throw new OAuthError("already_linked", `${provider} account is already linked`); } // Exchange code and get profile const { tokens, profile } = await this.handleCallback(provider, code); + return this.linkProfile(userId, provider, profile, tokens); + } + + /** + * Link an already-fetched provider profile to an existing (authenticated) user. + * + * The caller is responsible for having authenticated `userId` and for having + * verified the OAuth state that binds this callback to that user. + * + * @param userId - ID of the authenticated user + * @param provider - OAuth provider + * @param profile - Provider profile obtained from the callback + * @param tokens - Provider token response + * @returns The created OAuth account link + * @throws {OAuthError} When the user already has this provider linked, or the + * provider account is linked to another user + */ + async linkProfile( + userId: string, + provider: OAuthProvider, + profile: OAuthUserProfile, + tokens: OAuthTokenResponse, + ): Promise { + const existing = await this.oauthAccountRepository.findByUserAndProvider(userId, provider); + if (existing) { + throw new OAuthError("already_linked", `${provider} account is already linked`); + } + // Check if this provider account is linked to another user const otherLink = await this.oauthAccountRepository.findByProviderAccount(provider, profile.id); if (otherLink) { - throw new Error(`This ${provider} account is already linked to another user`); + throw new OAuthError( + "linked_to_other_user", + `This ${provider} account is already linked to another user`, + ); } - // Create link - const oauthAccount = await this.oauthAccountRepository.create({ - id: generateId(), - userId, - provider, - providerAccountId: profile.id, - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || null, - tokenExpiresAt: tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : null, - scope: tokens.scope || null, - tokenType: tokens.token_type || "Bearer", - providerUsername: profile.username, - providerEmail: profile.email, - }); + const oauthAccount = await this.createLink(userId, provider, profile, tokens); logger.info({ provider, userId }, "OAuth account linked"); return oauthAccount; @@ -769,8 +1002,9 @@ export class OAuthService { // Count linked OAuth accounts const linkedAccounts = await this.oauthAccountRepository.countByUserId(userId); - // User must have password or at least one other OAuth account - if (!user.passwordHash && linkedAccounts <= 1) { + // User must have a usable password or at least one other OAuth account. + // OAuth-created users carry a placeholder hash that can never be used to log in. + if (!hasUsablePassword(user.passwordHash) && linkedAccounts <= 1) { throw new Error("Cannot unlink the only login method. Set a password first."); } diff --git a/packages/backend/src/tests/unit/OAuthRoutesSecurity.test.ts b/packages/backend/src/tests/unit/OAuthRoutesSecurity.test.ts new file mode 100644 index 00000000..59acd892 --- /dev/null +++ b/packages/backend/src/tests/unit/OAuthRoutesSecurity.test.ts @@ -0,0 +1,333 @@ +/** + * OAuth / Registration Route Security Tests + * + * Covers: + * - OAuth callback refuses email auto-linking and gated sign-ups + * - OAuth link/unlink/accounts endpoints actually require authentication + * - Link-flow callback binds the provider to the session that initiated it + * - Legacy `POST /api/users` registration endpoint is removed + * - Shared registration policy helpers + */ + +import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; +import { Hono } from "hono"; +import oauthApp from "../../routes/oauth.js"; +import usersApp from "../../routes/users.js"; +import { getRegistrationPolicy } from "../../lib/registrationPolicy.js"; +import type { User, OAuthAccount } from "../../db/schema/pg.js"; + +const baseUser: User = { + id: "user1", + username: "alice", + email: "alice@example.com", + passwordHash: "$argon2id$hash", + displayName: "Alice", + host: null, + avatarUrl: null, + bannerUrl: null, + bio: null, + isAdmin: false, + isSuspended: false, + isDeleted: false, + deletedAt: null, + isSystemUser: false, + publicKey: "pk", + privateKey: "sk", + inbox: null, + outbox: null, + followersUrl: null, + followingUrl: null, + uri: null, + sharedInbox: null, + customCss: null, + uiSettings: null, + alsoKnownAs: [], + movedTo: null, + movedAt: null, + profileEmojis: [], + storageQuotaMb: null, + goneDetectedAt: null, + fetchFailureCount: 0, + lastFetchAttemptAt: null, + lastFetchError: null, + followersCount: 0, + followingCount: 0, + createdAt: new Date(), + updatedAt: new Date(), +} as User; + +const linkRow: OAuthAccount = { + id: "oa1", + userId: "user1", + provider: "github", + providerAccountId: "42", + accessToken: "tok", + refreshToken: null, + tokenExpiresAt: null, + scope: null, + tokenType: "Bearer", + providerUsername: "gh", + providerEmail: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe("OAuth routes security", () => { + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + + let userRepo: Record>; + let oauthRepo: Record>; + let sessionRepo: Record>; + let settings: { + isRegistrationEnabled: ReturnType; + isInviteOnly: ReturnType; + }; + let app: Hono; + + beforeEach(() => { + process.env.URL = "http://localhost:3000"; + process.env.GITHUB_CLIENT_ID = "id"; + process.env.GITHUB_CLIENT_SECRET = "secret"; + delete process.env.REQUIRE_INVITATION; + delete process.env.ENABLE_REGISTRATION; + + // GitHub: token exchange, /user, /user/emails (verified primary email) + globalThis.fetch = mock((input: string | URL | Request) => { + const url = String(input); + let body: unknown = {}; + if (url.includes("/login/oauth/access_token")) { + body = { access_token: "tok", token_type: "bearer" }; + } else if (url.endsWith("/user")) { + body = { id: 42, login: "victim", name: null, email: null, avatar_url: null }; + } else if (url.endsWith("/user/emails")) { + body = [{ email: "alice@example.com", primary: true, verified: true }]; + } + return Promise.resolve(new Response(JSON.stringify(body), { status: 200 })); + }) as unknown as typeof fetch; + + userRepo = { + findByEmail: mock(() => Promise.resolve(null)), + findByUsername: mock(() => Promise.resolve(null)), + findById: mock(() => Promise.resolve(baseUser)), + create: mock((u: User) => Promise.resolve({ ...baseUser, ...u })), + }; + oauthRepo = { + findByProviderAccount: mock(() => Promise.resolve(null)), + findByUserAndProvider: mock(() => Promise.resolve(null)), + create: mock(() => Promise.resolve(linkRow)), + updateTokens: mock(() => Promise.resolve(linkRow)), + findByUserId: mock(() => Promise.resolve([linkRow])), + }; + sessionRepo = { + create: mock(() => Promise.resolve({})), + findByToken: mock((token: string) => + Promise.resolve( + token === "valid-session" + ? { + id: "s1", + userId: "user1", + token, + expiresAt: new Date(Date.now() + 60_000), + } + : null, + ), + ), + delete: mock(() => Promise.resolve()), + }; + settings = { + isRegistrationEnabled: mock(() => Promise.resolve(true)), + isInviteOnly: mock(() => Promise.resolve(false)), + }; + + app = new Hono(); + app.use("*", async (c, next) => { + c.set("userRepository" as any, userRepo as any); + c.set("oauthAccountRepository" as any, oauthRepo as any); + c.set("sessionRepository" as any, sessionRepo as any); + c.set("instanceSettingsService" as any, settings as any); + c.set( + "blockedUsernameService" as any, + { + isUsernameBlocked: mock(() => Promise.resolve({ blocked: false })), + } as any, + ); + await next(); + }); + app.route("/api/auth/oauth", oauthApp); + app.route("/api/users", usersApp); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + }); + + const callback = (cookie: string) => + app.request("/api/auth/oauth/github/callback?code=abc&state=st", { + headers: { Cookie: cookie }, + }); + + const errorOf = (res: Response) => + new URL(res.headers.get("location")!, "http://x").searchParams.get("error"); + + test("callback refuses to auto-link an existing account by email", async () => { + userRepo.findByEmail!.mockResolvedValue(baseUser); + + const res = await callback("oauth_state=st"); + + expect(res.status).toBe(302); + expect(res.headers.get("location")!.startsWith("/login?")).toBe(true); + expect(errorOf(res)).toContain("An account with this email already exists"); + expect(oauthRepo.create).not.toHaveBeenCalled(); + expect(sessionRepo.create).not.toHaveBeenCalled(); + }); + + test("callback refuses OAuth sign-up when invite-only", async () => { + settings.isInviteOnly.mockResolvedValue(true); + + const res = await callback("oauth_state=st"); + + expect(errorOf(res)).toContain("invitation code"); + expect(userRepo.create).not.toHaveBeenCalled(); + }); + + test("callback refuses OAuth sign-up when REQUIRE_INVITATION env is set", async () => { + process.env.REQUIRE_INVITATION = "true"; + + const res = await callback("oauth_state=st"); + + expect(errorOf(res)).toContain("invitation code"); + expect(userRepo.create).not.toHaveBeenCalled(); + }); + + test("callback refuses OAuth sign-up when registration is disabled", async () => { + settings.isRegistrationEnabled.mockResolvedValue(false); + + const res = await callback("oauth_state=st"); + + expect(errorOf(res)).toBe("Registration is currently disabled"); + expect(userRepo.create).not.toHaveBeenCalled(); + }); + + test("callback still logs in already-linked users when invite-only", async () => { + settings.isInviteOnly.mockResolvedValue(true); + oauthRepo.findByProviderAccount!.mockResolvedValue(linkRow); + + const res = await callback("oauth_state=st"); + + expect(res.headers.get("location")).toBe("/"); + expect(sessionRepo.create).toHaveBeenCalled(); + }); + + test("callback rejects state mismatch", async () => { + const res = await callback("oauth_state=other"); + expect(errorOf(res)).toBe("Invalid OAuth state"); + }); + + test("link-flow callback links to the session that initiated it", async () => { + const res = await callback("oauth_link_state=st.valid-session"); + + expect(res.headers.get("location")).toBe("/settings?oauth_linked=github"); + expect(oauthRepo.create).toHaveBeenCalled(); + const created = oauthRepo.create!.mock.calls[0]![0] as OAuthAccount; + expect(created.userId).toBe("user1"); + expect(sessionRepo.create).not.toHaveBeenCalled(); + }); + + test("link-flow callback with an invalid session does not link", async () => { + const res = await callback("oauth_link_state=st.forged-or-expired"); + + expect(res.headers.get("location")!.startsWith("/login?")).toBe(true); + expect(oauthRepo.create).not.toHaveBeenCalled(); + }); + + test("link/unlink/accounts endpoints require authentication", async () => { + const accounts = await app.request("/api/auth/oauth/accounts"); + expect(accounts.status).toBe(401); + + const link = await app.request("/api/auth/oauth/github/link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: "x", state: "y" }), + }); + expect(link.status).toBe(401); + + const linkAuthorize = await app.request("/api/auth/oauth/github/link/authorize", { + method: "POST", + }); + expect(linkAuthorize.status).toBe(401); + + const unlink = await app.request("/api/auth/oauth/github", { method: "DELETE" }); + expect(unlink.status).toBe(401); + }); + + test("authenticated user can list linked accounts", async () => { + const res = await app.request("/api/auth/oauth/accounts", { + headers: { Authorization: "Bearer valid-session" }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { accounts: unknown[] }; + expect(body.accounts.length).toBe(1); + }); + + test("POST /link requires state bound to the caller's session", async () => { + const res = await app.request("/api/auth/oauth/github/link", { + method: "POST", + headers: { + Authorization: "Bearer valid-session", + "Content-Type": "application/json", + Cookie: "oauth_link_state=st.some-other-session", + }, + body: JSON.stringify({ code: "abc", state: "st" }), + }); + expect(res.status).toBe(400); + expect(oauthRepo.create).not.toHaveBeenCalled(); + }); + + test("legacy POST /api/users registration endpoint is removed", async () => { + process.env.ENABLE_REGISTRATION = "true"; + const res = await app.request("/api/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "mallory", + email: "mallory@example.com", + password: "password123", + }), + }); + expect(res.status).toBe(404); + expect(userRepo.create).not.toHaveBeenCalled(); + }); +}); + +describe("getRegistrationPolicy", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + const source = (enabled: boolean, inviteOnly: boolean) => ({ + isRegistrationEnabled: () => Promise.resolve(enabled), + isInviteOnly: () => Promise.resolve(inviteOnly), + }); + + test("reflects DB settings", async () => { + delete process.env.REQUIRE_INVITATION; + expect(await getRegistrationPolicy(source(true, false))).toEqual({ + registrationEnabled: true, + inviteOnly: false, + }); + expect(await getRegistrationPolicy(source(false, true))).toEqual({ + registrationEnabled: false, + inviteOnly: true, + }); + }); + + test("REQUIRE_INVITATION env forces invite-only", async () => { + process.env.REQUIRE_INVITATION = "1"; + const policy = await getRegistrationPolicy(source(true, false)); + expect(policy.inviteOnly).toBe(true); + }); +}); diff --git a/packages/backend/src/tests/unit/OAuthService.test.ts b/packages/backend/src/tests/unit/OAuthService.test.ts index def2bb01..2b596500 100644 --- a/packages/backend/src/tests/unit/OAuthService.test.ts +++ b/packages/backend/src/tests/unit/OAuthService.test.ts @@ -6,7 +6,13 @@ */ import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; -import { OAuthService } from "../../services/OAuthService"; +import { + OAuthService, + OAuthError, + hasUsablePassword, + isGoogleEmailVerified, + selectVerifiedGitHubEmail, +} from "../../services/OAuthService"; import type { IOAuthAccountRepository } from "../../interfaces/repositories/IOAuthAccountRepository"; import type { IUserRepository } from "../../interfaces/repositories/IUserRepository"; import type { ISessionRepository } from "../../interfaces/repositories/ISessionRepository"; @@ -421,4 +427,288 @@ describe("OAuthService", () => { expect(url).toContain("email"); }); }); + + describe("findOrCreateUser security", () => { + const tokens = { access_token: "tok", token_type: "Bearer" }; + const openPolicy = { registrationEnabled: true, inviteOnly: false }; + const profile = { + id: "999", + username: "victim", + email: "victim@example.com", + displayName: "Victim", + avatarUrl: null, + }; + + const makeService = (blocked?: (name: string) => boolean) => + new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + blocked + ? { + isUsernameBlocked: mock((name: string) => + Promise.resolve({ blocked: blocked(name) }), + ), + } + : undefined, + ); + + test("logs in an already-linked provider account", async () => { + (mockOAuthRepo.findByProviderAccount as ReturnType).mockResolvedValue( + mockOAuthAccount, + ); + + const result = await makeService().findOrCreateUser("github", profile, tokens, { + registrationEnabled: false, + inviteOnly: true, + }); + + // Existing links keep working even when registration is closed + expect(result.isNew).toBe(false); + expect(result.user.id).toBe("user1"); + expect(mockUserRepo.create).not.toHaveBeenCalled(); + expect(mockOAuthRepo.create).not.toHaveBeenCalled(); + }); + + test("never auto-links to an existing account with the same email", async () => { + (mockUserRepo.findByEmail as ReturnType).mockResolvedValue(mockUser); + + await expect( + makeService().findOrCreateUser("github", profile, tokens, openPolicy), + ).rejects.toThrow("An account with this email already exists"); + + expect(mockOAuthRepo.create).not.toHaveBeenCalled(); + expect(mockUserRepo.create).not.toHaveBeenCalled(); + }); + + test("throws OAuthError with email_exists code", async () => { + (mockUserRepo.findByEmail as ReturnType).mockResolvedValue(mockUser); + + try { + await makeService().findOrCreateUser("github", profile, tokens, openPolicy); + throw new Error("expected rejection"); + } catch (err) { + expect(err).toBeInstanceOf(OAuthError); + expect((err as OAuthError).code).toBe("email_exists"); + } + }); + + test("refuses sign-up when registration is disabled", async () => { + await expect( + makeService().findOrCreateUser("github", profile, tokens, { + registrationEnabled: false, + inviteOnly: false, + }), + ).rejects.toThrow("Registration is currently disabled"); + expect(mockUserRepo.create).not.toHaveBeenCalled(); + }); + + test("refuses sign-up when invite-only", async () => { + await expect( + makeService().findOrCreateUser("github", profile, tokens, { + registrationEnabled: true, + inviteOnly: true, + }), + ).rejects.toThrow("invitation code"); + expect(mockUserRepo.create).not.toHaveBeenCalled(); + }); + + test("creates a new user with verified email when allowed", async () => { + const result = await makeService().findOrCreateUser("github", profile, tokens, openPolicy); + + expect(result.isNew).toBe(true); + const created = (mockUserRepo.create as ReturnType).mock.calls[0]![0]; + expect(created.email).toBe("victim@example.com"); + expect(created.username).toBe("victim"); + expect(mockOAuthRepo.create).toHaveBeenCalled(); + }); + + test("uses a placeholder email when the provider email is not verified", async () => { + await makeService().findOrCreateUser( + "discord", + { ...profile, email: null }, + tokens, + openPolicy, + ); + + expect(mockUserRepo.findByEmail).not.toHaveBeenCalled(); + const created = (mockUserRepo.create as ReturnType).mock.calls[0]![0]; + expect(created.email).toBe("999@discord.oauth"); + }); + + test("does not use a blocked username", async () => { + await makeService((name) => name === "admin").findOrCreateUser( + "github", + { ...profile, username: "admin" }, + tokens, + openPolicy, + ); + + const created = (mockUserRepo.create as ReturnType).mock.calls[0]![0]; + expect(created.username).not.toBe("admin"); + expect(created.username.startsWith("user")).toBe(true); + }); + + test("refuses sign-up if no allowed username can be derived", async () => { + await expect( + makeService(() => true).findOrCreateUser("github", profile, tokens, openPolicy), + ).rejects.toThrow("allowed username"); + expect(mockUserRepo.create).not.toHaveBeenCalled(); + }); + }); + + describe("provider email verification", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + const mockFetchSequence = (bodies: unknown[]) => { + let i = 0; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify(bodies[i++]), { status: 200 })), + ) as unknown as typeof fetch; + }; + + const getProfile = (service: OAuthService, method: string, ...args: unknown[]) => + (service as any)[method](...args) as Promise<{ email: string | null }>; + + test("drops unverified Discord email", async () => { + mockFetchSequence([{ id: "1", username: "u", email: "v@example.com", verified: false }]); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + expect((await getProfile(service, "getDiscordProfile", "t")).email).toBeNull(); + }); + + test("keeps verified Discord email", async () => { + mockFetchSequence([{ id: "1", username: "u", email: "v@example.com", verified: true }]); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + expect((await getProfile(service, "getDiscordProfile", "t")).email).toBe("v@example.com"); + }); + + test("drops unverified Google email", async () => { + mockFetchSequence([{ id: "1", email: "v@example.com", verified_email: false }]); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + expect((await getProfile(service, "getGoogleProfile", "t")).email).toBeNull(); + }); + + test("keeps verified Google email", async () => { + mockFetchSequence([{ id: "1", email: "v@example.com", verified_email: true }]); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + expect((await getProfile(service, "getGoogleProfile", "t")).email).toBe("v@example.com"); + }); + + test("drops unverified GitHub public email", async () => { + mockFetchSequence([ + { id: 1, login: "u", name: null, email: "v@example.com", avatar_url: null }, + [{ email: "v@example.com", primary: true, verified: false }], + ]); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + expect((await getProfile(service, "getGitHubProfile", "t")).email).toBeNull(); + }); + + test("selectVerifiedGitHubEmail prefers verified public email, then primary verified", () => { + const emails = [ + { email: "primary@example.com", primary: true, verified: true }, + { email: "public@example.com", primary: false, verified: true }, + { email: "unverified@example.com", primary: false, verified: false }, + ]; + expect(selectVerifiedGitHubEmail("public@example.com", emails)).toBe("public@example.com"); + expect(selectVerifiedGitHubEmail("unverified@example.com", emails)).toBe( + "primary@example.com", + ); + expect(selectVerifiedGitHubEmail(null, emails)).toBe("primary@example.com"); + expect( + selectVerifiedGitHubEmail(null, [{ email: "a@b.c", primary: true, verified: false }]), + ).toBeNull(); + }); + + test("isGoogleEmailVerified requires an explicit true flag", () => { + expect(isGoogleEmailVerified({})).toBe(false); + expect(isGoogleEmailVerified({ verified_email: false })).toBe(false); + expect(isGoogleEmailVerified({ verified_email: true })).toBe(true); + expect(isGoogleEmailVerified({ email_verified: true })).toBe(true); + }); + }); + + describe("linkProfile", () => { + const tokens = { access_token: "tok", token_type: "Bearer" }; + const profile = { + id: "555", + username: "gh", + email: null, + displayName: null, + avatarUrl: null, + }; + + test("links provider to the given user", async () => { + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + await service.linkProfile("user1", "github", profile, tokens); + const created = (mockOAuthRepo.create as ReturnType).mock.calls[0]![0]; + expect(created.userId).toBe("user1"); + expect(created.providerAccountId).toBe("555"); + }); + + test("refuses if provider account is linked to another user", async () => { + (mockOAuthRepo.findByProviderAccount as ReturnType).mockResolvedValue({ + ...mockOAuthAccount, + userId: "other", + }); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + await expect(service.linkProfile("user1", "github", profile, tokens)).rejects.toThrow( + "already linked to another user", + ); + expect(mockOAuthRepo.create).not.toHaveBeenCalled(); + }); + }); + + describe("hasUsablePassword", () => { + test("treats OAuth placeholder hashes as unusable", () => { + expect(hasUsablePassword("oauth:github:abc")).toBe(false); + expect(hasUsablePassword(null)).toBe(false); + expect(hasUsablePassword("")).toBe(false); + expect(hasUsablePassword("$argon2id$v=19$hash")).toBe(true); + }); + + test("prevents OAuth-only users from unlinking their only login method", async () => { + // mockUser has an OAuth placeholder hash and a single link + (mockOAuthRepo.countByUserId as ReturnType).mockResolvedValue(1); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + await expect(service.unlinkAccount("user1", "github")).rejects.toThrow( + "Cannot unlink the only login method", + ); + }); + }); }); diff --git a/packages/frontend/src/components/mfm/MfmRenderer.tsx b/packages/frontend/src/components/mfm/MfmRenderer.tsx index 44d5aed0..fae8da56 100644 --- a/packages/frontend/src/components/mfm/MfmRenderer.tsx +++ b/packages/frontend/src/components/mfm/MfmRenderer.tsx @@ -23,6 +23,7 @@ import katex from "katex"; import { lookupEmojis } from "../../lib/atoms/customEmoji"; import { containsHtml, htmlToMfm } from "../../lib/utils/htmlToText"; import { getProxiedImageUrl } from "../../lib/utils/imageProxy"; +import { toSafeHttpUrl } from "../../lib/utils/safeUrl"; /** * Props for MfmRenderer component @@ -287,11 +288,16 @@ function MfmRendererComponent({ ); - case "url": + case "url": { + // Remote content: only render http(s) URLs as links + const safeUrl = toSafeHttpUrl(node.props.url); + if (!safeUrl) { + return {node.props.url}; + } return ( ); + } - case "link": + case "link": { + const safeLinkUrl = toSafeHttpUrl(node.props.url); + if (!safeLinkUrl) { + return {node.children?.map((child, i) => renderNode(child, i))}; + } return ( renderNode(child, i))} ); + } case "mention": const mentionUsername = node.props.username; diff --git a/packages/frontend/src/components/note/NoteCardMenu.tsx b/packages/frontend/src/components/note/NoteCardMenu.tsx index 62e9497d..fe8dc178 100644 --- a/packages/frontend/src/components/note/NoteCardMenu.tsx +++ b/packages/frontend/src/components/note/NoteCardMenu.tsx @@ -5,6 +5,7 @@ import { Trans } from "@lingui/react/macro"; import { t } from "@lingui/core/macro"; import { MoreHorizontal, Share, Copy, ExternalLink, Trash2, Flag } from "lucide-react"; import { Button } from "../ui/Button"; +import { toSafeHttpUrl } from "../../lib/utils/safeUrl"; /** * Props for the NoteCardMenu component @@ -66,11 +67,13 @@ export function NoteCardMenu({ }, [showMoreMenu]); /** - * Get the public URL for this note + * Get the public URL for this note. + * Remote `noteUri` is only used when it is an http(s) URL. */ const getNoteUrl = (): string => { - if (noteUri) { - return noteUri; + const safeRemoteUrl = toSafeHttpUrl(noteUri); + if (safeRemoteUrl) { + return safeRemoteUrl; } const baseUrl = typeof window !== "undefined" ? window.location.origin : ""; return `${baseUrl}/notes/${noteId}`; diff --git a/packages/frontend/src/lib/utils/safeUrl.ts b/packages/frontend/src/lib/utils/safeUrl.ts new file mode 100644 index 00000000..3c2c0ad9 --- /dev/null +++ b/packages/frontend/src/lib/utils/safeUrl.ts @@ -0,0 +1,31 @@ +/** + * Safe URL utilities + * + * Guards against remote-controlled URLs (e.g. ActivityPub `note.uri`, MFM links) + * with dangerous schemes such as `javascript:` or `data:` being placed into + * `href`, `window.open`, `location`, or the clipboard. + */ + +/** + * Return the URL only if it is an absolute `http:` or `https:` URL. + * + * @param url - Untrusted URL string + * @returns The normalized URL string, or null if it is missing, unparsable, or + * uses any other scheme + * + * @example + * ```ts + * toSafeHttpUrl("https://example.com/notes/1"); // "https://example.com/notes/1" + * toSafeHttpUrl("javascript:alert(1)"); // null + * ``` + */ +export function toSafeHttpUrl(url: string | null | undefined): string | null { + if (!url || typeof url !== "string") return null; + try { + const parsed = new URL(url.trim()); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return parsed.href; + } catch { + return null; + } +} diff --git a/packages/frontend/src/tests/safeUrl.test.ts b/packages/frontend/src/tests/safeUrl.test.ts new file mode 100644 index 00000000..998984b7 --- /dev/null +++ b/packages/frontend/src/tests/safeUrl.test.ts @@ -0,0 +1,38 @@ +/** + * safeUrl Utility Tests + * + * Ensures remote-controlled URLs with dangerous schemes are rejected. + */ + +import { describe, test, expect } from "bun:test"; +import { toSafeHttpUrl } from "../lib/utils/safeUrl"; + +describe("toSafeHttpUrl", () => { + test("accepts http and https URLs", () => { + expect(toSafeHttpUrl("https://remote.example/notes/1")).toBe("https://remote.example/notes/1"); + expect(toSafeHttpUrl("http://remote.example/@alice")).toBe("http://remote.example/@alice"); + expect(toSafeHttpUrl(" https://remote.example/x ")).toBe("https://remote.example/x"); + }); + + test("rejects javascript: URLs in any casing or with whitespace", () => { + expect(toSafeHttpUrl("javascript:alert(1)")).toBeNull(); + expect(toSafeHttpUrl("JavaScript:alert(1)")).toBeNull(); + expect(toSafeHttpUrl(" javascript:alert(document.cookie)")).toBeNull(); + expect(toSafeHttpUrl("java\tscript:alert(1)")).toBeNull(); + }); + + test("rejects other non-http schemes", () => { + expect(toSafeHttpUrl("data:text/html,")).toBeNull(); + expect(toSafeHttpUrl("vbscript:msgbox(1)")).toBeNull(); + expect(toSafeHttpUrl("file:///etc/passwd")).toBeNull(); + expect(toSafeHttpUrl("ftp://example.com/file")).toBeNull(); + }); + + test("rejects relative, empty, and malformed values", () => { + expect(toSafeHttpUrl("/notes/1")).toBeNull(); + expect(toSafeHttpUrl("")).toBeNull(); + expect(toSafeHttpUrl(null)).toBeNull(); + expect(toSafeHttpUrl(undefined)).toBeNull(); + expect(toSafeHttpUrl("not a url")).toBeNull(); + }); +});