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
93 changes: 93 additions & 0 deletions src/lib/actions/auth-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
import * as Sentry from '@sentry/nextjs'
import type { SupabaseClient, User } from '@supabase/supabase-js'

import { checkRateLimit } from '@/lib/rate-limit/check'
import type { RateLimitKey } from '@/lib/rate-limit/config'
import { createClient } from '@/lib/supabase/server'
import type { Database } from '@/lib/supabase/types'

Expand Down Expand Up @@ -111,3 +113,94 @@ export async function withAuthResult<T>(
}
}
}

/**
* Wraps a Server Action with authentication AND rate limiting.
* Returns ActionResult<T> (does not throw).
*
* Combines auth check → rate limit check → action execution.
* Use this for mutation server actions that need abuse protection.
*
* @param rateLimitKey - Rate limit configuration key
* @param action - Async function receiving authenticated supabase client and user
* @returns ActionResult<T> with success/data or error
*
* @example
* export const createBoard = (name: string) =>
* withAuthResultRateLimit('boardCrud', async (supabase, user) => {
* const { data } = await supabase.from('board').insert({ name, user_id: user.id }).select().single()
* return data
* })
*/
export async function withAuthResultRateLimit<T>(
rateLimitKey: RateLimitKey,
action: (supabase: SupabaseClient<Database>, user: User) => Promise<T>,
): Promise<ActionResult<T>> {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()

if (authError || !user) {
return { success: false, error: 'Authentication required' }
}

// Rate limit check using user.id
const rlResult = checkRateLimit(rateLimitKey, user.id)
if (!rlResult.allowed) {
return { success: false, error: rlResult.error! }
}

try {
const data = await action(supabase, user)
return { success: true, data }
} catch (error) {
Sentry.captureException(error, {
extra: { context: `withAuthResultRateLimit:${rateLimitKey}` },
})
return {
success: false,
error:
error instanceof Error ? error.message : 'An unexpected error occurred',
}
}
}

/**
* Wraps a Server Action with authentication AND rate limiting.
* Throws on auth/rate-limit failure (for actions that use throw-based error handling).
*
* @param rateLimitKey - Rate limit configuration key
* @param action - Async function receiving authenticated supabase client and user
* @returns The action's return value
* @throws {Error} 'Authentication required' or rate limit error message
*
* @example
* export const batchUpdateOrders = (updates: Array<...>) =>
* withAuthRateLimit('batchDnD', async (supabase, user) => {
* await supabase.rpc('batch_update', { p_updates: updates })
* })
*/
export async function withAuthRateLimit<T>(
rateLimitKey: RateLimitKey,
action: (supabase: SupabaseClient<Database>, user: User) => Promise<T>,
): Promise<T> {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()

if (authError || !user) {
throw new Error('Authentication required')
}

// Rate limit check using user.id
const rlResult = checkRateLimit(rateLimitKey, user.id)
if (!rlResult.allowed) {
throw new Error(rlResult.error!)
}

return action(supabase, user)
}
27 changes: 25 additions & 2 deletions src/lib/actions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { cookies, headers } from 'next/headers'
import { redirect } from 'next/navigation'

import { getGitHubTokenCookieName } from '@/lib/constants/cookies'
import { checkRateLimit } from '@/lib/rate-limit/check'
import { logSecurityEvent } from '@/lib/security-events'
import {
createServerActionClient,
Expand All @@ -26,9 +27,25 @@ import {
* @returns Redirect URL to GitHub authentication screen
*/
export async function signInWithGitHub() {
// Rate limit by IP (user not yet authenticated)
const headerStore = await headers()
const forwarded = headerStore.get('x-forwarded-for')?.split(',')[0]?.trim()
if (!forwarded) {
Sentry.captureMessage('signInWithGitHub: x-forwarded-for header missing', {
level: 'warning',
tags: { category: 'rate_limit' },
})
}
const ip = forwarded || '127.0.0.1'
const rlResult = checkRateLimit('signInWithGitHub', ip)
if (!rlResult.allowed) {
redirect(
`/login?error=rate_limited&message=${encodeURIComponent(rlResult.error!)}`,
)
}
Comment thread
ryota-murakami marked this conversation as resolved.

const supabase = await createServerActionClient()
const origin =
(await headers()).get('origin') ?? process.env.NEXT_PUBLIC_SITE_URL
const origin = headerStore.get('origin') ?? process.env.NEXT_PUBLIC_SITE_URL

const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
Expand Down Expand Up @@ -113,6 +130,12 @@ export async function deleteAccount() {
throw new Error('Not authenticated')
}

// Rate limit by user ID
const rlResult = checkRateLimit('deleteAccount', user.id)
if (!rlResult.allowed) {
throw new Error(rlResult.error!)
}

// Use admin client to delete user (bypasses RLS)
const adminClient = createAdminClient()
const { error: deleteError } = await adminClient.auth.admin.deleteUser(
Expand Down
18 changes: 11 additions & 7 deletions src/lib/actions/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@

import * as Sentry from '@sentry/nextjs'

import { withAuth, withAuthResult } from '@/lib/actions/auth-guard'
import {
withAuth,
withAuthRateLimit,
withAuthResultRateLimit,
} from '@/lib/actions/auth-guard'
import { toStatusListDomain } from '@/lib/actions/mappers'
import type {
StatusListDomain,
Expand Down Expand Up @@ -294,7 +298,7 @@ export async function swapStatusListPositions(
id1: string,
id2: string,
): Promise<void> {
return withAuth(async (supabase) => {
return withAuthRateLimit('batchDnD', async (supabase) => {
const { error } = await supabase.rpc('swap_statuslist_positions', {
id_a: id1,
id_b: id2,
Expand Down Expand Up @@ -327,7 +331,7 @@ export async function swapStatusListPositions(
export async function batchUpdateStatusListPositions(
updates: Array<{ id: string; gridRow: number; gridCol: number }>,
): Promise<void> {
return withAuth(async (supabase) => {
return withAuthRateLimit('batchDnD', async (supabase) => {
// Atomic batch update via PostgreSQL RPC — all updates succeed or all fail
const { error } = await supabase.rpc('batch_update_statuslist_positions', {
p_updates: updates.map(({ id, gridRow, gridCol }) => ({
Expand Down Expand Up @@ -437,7 +441,7 @@ export async function updateRepoCardPosition(
export async function batchUpdateRepoCardOrders(
updates: Array<{ id: string; statusId: string; order: number }>,
): Promise<void> {
return withAuth(async (supabase) => {
return withAuthRateLimit('batchDnD', async (supabase) => {
// Atomic batch update via PostgreSQL RPC — all updates succeed or all fail
const { error } = await supabase.rpc('batch_update_repocard_orders', {
p_updates: updates.map(({ id, statusId, order }) => ({
Expand Down Expand Up @@ -515,7 +519,7 @@ export async function createBoard(
}
}

return withAuthResult(async (supabase, user) => {
return withAuthResultRateLimit('boardCrud', async (supabase, user) => {
// Auto-assign position: append to end of user's board list
const { data: maxPositionRow } = await supabase
.from('board')
Expand Down Expand Up @@ -576,7 +580,7 @@ export async function updateBoardPositions(
}
}

return withAuthResult(async (supabase) => {
return withAuthResultRateLimit('batchDnD', async (supabase) => {
// Atomic batch update via PostgreSQL RPC — all updates succeed or all fail
// Note: RLS policies on board table still enforce user ownership
const { error } = await supabase.rpc('batch_update_board_positions', {
Expand All @@ -593,7 +597,7 @@ export async function updateBoardPositions(
* Delete a board
*/
export async function deleteBoard(boardId: string): Promise<void> {
return withAuth(async (supabase) => {
return withAuthRateLimit('boardCrud', async (supabase) => {
const { error } = await supabase.from('board').delete().eq('id', boardId)

if (error) {
Expand Down
44 changes: 44 additions & 0 deletions src/lib/actions/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,30 @@

import * as Sentry from '@sentry/nextjs'
import { isAxiosError } from 'axios'
import { headers } from 'next/headers'

import { createGitHubAxios, hasGitHubToken } from '@/lib/axios-github'
import { createModuleLogger } from '@/lib/logger'
import { checkRateLimit } from '@/lib/rate-limit/check'

import type { ActionResult } from './types'

const log = createModuleLogger('github')

/**
* Get client IP from request headers for rate limiting.
* GitHub API functions don't use Supabase auth, so we use IP-based limiting.
* Logs a warning when x-forwarded-for is missing (proxy misconfiguration).
*/
async function getClientIp(): Promise<string> {
const headerStore = await headers()
const forwarded = headerStore.get('x-forwarded-for')?.split(',')[0]?.trim()
if (!forwarded) {
log.warn('x-forwarded-for header missing — falling back to 127.0.0.1')
}
return forwarded || '127.0.0.1'
}

export interface GitHubRepository {
id: number
node_id: string
Expand Down Expand Up @@ -127,6 +143,13 @@ export async function getAuthenticatedUserRepositories(params?: {
}
}

// Rate limit GitHub API calls by IP
const ip = await getClientIp()
const rlResult = checkRateLimit('githubApi', ip)
if (!rlResult.allowed) {
return { success: false, error: rlResult.error! }
}

const api = createGitHubAxios()

try {
Expand Down Expand Up @@ -202,6 +225,13 @@ export async function getAuthenticatedUser(): Promise<
}
}

// Rate limit GitHub API calls by IP
const ip = await getClientIp()
const rlResult = checkRateLimit('githubApi', ip)
if (!rlResult.allowed) {
return { success: false, error: rlResult.error! }
}

const api = createGitHubAxios()

try {
Expand Down Expand Up @@ -243,6 +273,13 @@ export async function getOrganizationRepositories(
}
}

// Rate limit GitHub API calls by IP
const ip = await getClientIp()
const rlResult = checkRateLimit('githubApi', ip)
if (!rlResult.allowed) {
return { success: false, error: rlResult.error! }
}

const api = createGitHubAxios()

try {
Expand Down Expand Up @@ -320,6 +357,13 @@ export async function getAuthenticatedUserOrganizations(): Promise<
}
}

// Rate limit GitHub API calls by IP
const ip = await getClientIp()
const rlResult = checkRateLimit('githubApi', ip)
if (!rlResult.allowed) {
return { success: false, error: rlResult.error! }
}

const api = createGitHubAxios()

try {
Expand Down
11 changes: 11 additions & 0 deletions src/lib/actions/repo-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as Sentry from '@sentry/nextjs'

import type { GitHubRepository } from '@/lib/actions/github'
import { createModuleLogger } from '@/lib/logger'
import { checkRateLimit } from '@/lib/rate-limit/check'
import { createClient } from '@/lib/supabase/server'

import type { ActionResult } from './types'
Expand Down Expand Up @@ -81,6 +82,16 @@ export async function addRepositoriesToBoard(
throw new Error('Authentication required')
}

// Rate limit add repository operations
const rlResult = checkRateLimit('addReposToBoard', user.id)
if (!rlResult.allowed) {
return {
success: false,
addedCount: 0,
errors: [rlResult.error!],
}
}

// Check if board exists and user owns it
const { data: board, error: boardError } = await supabase
.from('board')
Expand Down
Loading