🛂 Auto ban IP on suspected bot publishing (#1095)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Enhanced sign-in error handling with specific messages for different error types. - Implemented IP-based restrictions for authentication and publishing actions. - **Bug Fixes** - Updated the retrieval of user session information to improve reliability. - **Documentation** - Updated usage instructions for `getServerSession` to reflect the new authentication options. - **Refactor** - Replaced direct usage of `authOptions` with a new function `getAuthOptions` to dynamically generate authentication options. - Improved IP address extraction logic to handle various header formats. - **Chores** - Added a new `BannedIp` model to the database schema for managing IP-based restrictions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@ -78,17 +78,29 @@ export const SignInForm = ({
|
||||
redirect: false,
|
||||
})
|
||||
if (response?.error) {
|
||||
showToast({
|
||||
title: t('auth.signinErrorToast.title'),
|
||||
description: t('auth.signinErrorToast.description'),
|
||||
})
|
||||
if (response.error.includes('ip-banned'))
|
||||
showToast({
|
||||
status: 'info',
|
||||
description:
|
||||
'Your account has suspicious activity and is being reviewed by our team. Feel free to contact us.',
|
||||
})
|
||||
else if (response.error.includes('rate-limited'))
|
||||
showToast({
|
||||
status: 'info',
|
||||
description: t('auth.signinErrorToast.tooManyRequests'),
|
||||
})
|
||||
else
|
||||
showToast({
|
||||
title: t('auth.signinErrorToast.title'),
|
||||
description: t('auth.signinErrorToast.description'),
|
||||
})
|
||||
} else {
|
||||
setIsMagicLinkSent(true)
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
showToast({
|
||||
status: 'info',
|
||||
description: t('auth.signinErrorToast.tooManyRequests'),
|
||||
description: 'An error occured while signing in',
|
||||
})
|
||||
}
|
||||
setAuthLoading(false)
|
||||
|
@ -1,5 +1,5 @@
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { User } from '@typebot.io/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
@ -15,7 +15,7 @@ export const getAuthenticatedUser = async (
|
||||
if (bearerToken) return authenticateByToken(bearerToken)
|
||||
const user = env.NEXT_PUBLIC_E2E_TEST
|
||||
? mockedUser
|
||||
: ((await getServerSession(req, res, authOptions))?.user as
|
||||
: ((await getServerSession(req, res, getAuthOptions({})))?.user as
|
||||
| User
|
||||
| undefined)
|
||||
if (!user || !('id' in user)) return
|
||||
|
@ -32,6 +32,7 @@ import { parseDefaultPublicId } from '../helpers/parseDefaultPublicId'
|
||||
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
|
||||
import { ConfirmModal } from '@/components/ConfirmModal'
|
||||
import { TextLink } from '@/components/TextLink'
|
||||
import { useUser } from '@/features/account/hooks/useUser'
|
||||
|
||||
type Props = ButtonProps & {
|
||||
isMoreMenuDisabled?: boolean
|
||||
@ -44,6 +45,7 @@ export const PublishButton = ({
|
||||
const { workspace } = useWorkspace()
|
||||
const { push, query, pathname } = useRouter()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { logOut } = useUser()
|
||||
const {
|
||||
isOpen: isNewEngineWarningOpen,
|
||||
onOpen: onNewEngineWarningOpen,
|
||||
@ -69,11 +71,13 @@ export const PublishButton = ({
|
||||
|
||||
const { mutate: publishTypebotMutate, isLoading: isPublishing } =
|
||||
trpc.typebot.publishTypebot.useMutation({
|
||||
onError: (error) =>
|
||||
onError: (error) => {
|
||||
showToast({
|
||||
title: 'Error while publishing typebot',
|
||||
description: error.message,
|
||||
}),
|
||||
})
|
||||
if (error.data?.httpStatus === 403) logOut()
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchPublishedTypebot({
|
||||
typebotId: typebot?.id as string,
|
||||
|
@ -37,7 +37,7 @@ export const publishTypebot = authenticatedProcedure
|
||||
message: z.literal('success'),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input: { typebotId }, ctx: { user } }) => {
|
||||
.mutation(async ({ input: { typebotId }, ctx: { user, ip } }) => {
|
||||
const existingTypebot = await prisma.typebot.findFirst({
|
||||
where: {
|
||||
id: typebotId,
|
||||
@ -87,12 +87,16 @@ export const publishTypebot = authenticatedProcedure
|
||||
'Radar detected a potential malicious typebot. This bot is being manually reviewed by Fraud Prevention team.',
|
||||
})
|
||||
|
||||
const riskLevel = computeRiskLevel({
|
||||
name: existingTypebot.name,
|
||||
groups: parseGroups(existingTypebot.groups, {
|
||||
typebotVersion: existingTypebot.version,
|
||||
}),
|
||||
})
|
||||
const typebotWasVerified = existingTypebot.riskLevel === -1
|
||||
|
||||
const riskLevel = typebotWasVerified
|
||||
? 0
|
||||
: computeRiskLevel({
|
||||
name: existingTypebot.name,
|
||||
groups: parseGroups(existingTypebot.groups, {
|
||||
typebotVersion: existingTypebot.version,
|
||||
}),
|
||||
})
|
||||
|
||||
if (riskLevel > 0 && riskLevel !== existingTypebot.riskLevel) {
|
||||
if (env.MESSAGE_WEBHOOK_URL && riskLevel !== 100)
|
||||
@ -118,6 +122,21 @@ export const publishTypebot = authenticatedProcedure
|
||||
id: existingTypebot.publishedTypebot.id,
|
||||
},
|
||||
})
|
||||
if (ip) {
|
||||
const isIpAlreadyBanned = await prisma.bannedIp.findFirst({
|
||||
where: {
|
||||
ip,
|
||||
},
|
||||
})
|
||||
if (!isIpAlreadyBanned)
|
||||
await prisma.bannedIp.create({
|
||||
data: {
|
||||
ip,
|
||||
responsibleTypebotId: existingTypebot.id,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message:
|
||||
|
@ -1,12 +1,15 @@
|
||||
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
|
||||
import { inferAsyncReturnType } from '@trpc/server'
|
||||
import * as trpcNext from '@trpc/server/adapters/next'
|
||||
import { getIp } from '@typebot.io/lib/getIp'
|
||||
|
||||
export async function createContext(opts: trpcNext.CreateNextContextOptions) {
|
||||
const user = await getAuthenticatedUser(opts.req, opts.res)
|
||||
const ip = getIp(opts.req)
|
||||
|
||||
return {
|
||||
user,
|
||||
ip,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -19,6 +19,7 @@ import { Redis } from '@upstash/redis/nodejs'
|
||||
import got from 'got'
|
||||
import { env } from '@typebot.io/env'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { getIp } from '@typebot.io/lib/getIp'
|
||||
|
||||
const providers: Provider[] = []
|
||||
|
||||
@ -124,7 +125,11 @@ if (env.CUSTOM_OAUTH_WELL_KNOWN_URL) {
|
||||
})
|
||||
}
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
export const getAuthOptions = ({
|
||||
restricted,
|
||||
}: {
|
||||
restricted?: 'ip-banned' | 'rate-limited'
|
||||
}): AuthOptions => ({
|
||||
adapter: customAdapter(prisma),
|
||||
secret: env.ENCRYPTION_SECRET,
|
||||
providers,
|
||||
@ -153,6 +158,8 @@ export const authOptions: AuthOptions = {
|
||||
}
|
||||
},
|
||||
signIn: async ({ account, user }) => {
|
||||
if (restricted === 'ip-banned') throw new Error('ip-banned')
|
||||
if (restricted === 'rate-limited') throw new Error('rate-limited')
|
||||
if (!account) return false
|
||||
const isNewUser = !('createdAt' in user && isDefined(user.createdAt))
|
||||
if (isNewUser && user.email) {
|
||||
@ -177,7 +184,7 @@ export const authOptions: AuthOptions = {
|
||||
return true
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const isMockingSession =
|
||||
@ -188,24 +195,37 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const requestIsFromCompanyFirewall = req.method === 'HEAD'
|
||||
if (requestIsFromCompanyFirewall) return res.status(200).end()
|
||||
|
||||
let restricted: 'ip-banned' | 'rate-limited' | undefined
|
||||
|
||||
if (
|
||||
rateLimit &&
|
||||
req.url === '/api/auth/signin/email' &&
|
||||
env.RADAR_HIGH_RISK_KEYWORDS &&
|
||||
req.url?.startsWith('/api/auth/signin') &&
|
||||
req.method === 'POST'
|
||||
) {
|
||||
let ip = req.headers['x-real-ip'] as string | undefined
|
||||
if (!ip) {
|
||||
const forwardedFor = req.headers['x-forwarded-for']
|
||||
if (Array.isArray(forwardedFor)) {
|
||||
ip = forwardedFor.at(0)
|
||||
} else {
|
||||
ip = forwardedFor?.split(',').at(0) ?? 'Unknown'
|
||||
}
|
||||
const ip = getIp(req)
|
||||
if (ip) {
|
||||
const isIpBanned = await prisma.bannedIp.findFirst({
|
||||
where: {
|
||||
ip,
|
||||
},
|
||||
})
|
||||
if (isIpBanned) restricted = 'ip-banned'
|
||||
}
|
||||
const { success } = await rateLimit.limit(ip as string)
|
||||
if (!success) return res.status(429).json({ error: 'Too many requests' })
|
||||
}
|
||||
return await NextAuth(req, res, authOptions)
|
||||
|
||||
if (
|
||||
rateLimit &&
|
||||
req.url?.startsWith('/api/auth/signin/email') &&
|
||||
req.method === 'POST'
|
||||
) {
|
||||
const ip = getIp(req)
|
||||
if (ip) {
|
||||
const { success } = await rateLimit.limit(ip)
|
||||
if (!success) restricted = 'rate-limited'
|
||||
}
|
||||
}
|
||||
|
||||
return await NextAuth(req, res, getAuthOptions({ restricted }))
|
||||
}
|
||||
|
||||
const updateLastActivityDate = async (user: User) => {
|
||||
|
@ -3,7 +3,7 @@ import { User } from '@typebot.io/prisma'
|
||||
import { isNotDefined } from '@typebot.io/lib'
|
||||
import { sign } from 'jsonwebtoken'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from './api/auth/[...nextauth]'
|
||||
import { getAuthOptions } from './api/auth/[...nextauth]'
|
||||
import { env } from '@typebot.io/env'
|
||||
|
||||
export default function Page() {
|
||||
@ -11,7 +11,11 @@ export default function Page() {
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context.req, context.res, authOptions)
|
||||
const session = await getServerSession(
|
||||
context.req,
|
||||
context.res,
|
||||
getAuthOptions({})
|
||||
)
|
||||
if (isNotDefined(session?.user))
|
||||
return {
|
||||
redirect: {
|
||||
|
@ -2,7 +2,7 @@ import { getServerSession } from 'next-auth'
|
||||
import { User } from '@typebot.io/prisma'
|
||||
import { isNotDefined } from '@typebot.io/lib'
|
||||
import { sign } from 'jsonwebtoken'
|
||||
import { authOptions } from '../api/auth/[...nextauth]'
|
||||
import { getAuthOptions } from '../api/auth/[...nextauth]'
|
||||
import { GetServerSidePropsContext } from 'next'
|
||||
import { env } from '@typebot.io/env'
|
||||
|
||||
@ -11,7 +11,11 @@ export default function Page() {
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context.req, context.res, authOptions)
|
||||
const session = await getServerSession(
|
||||
context.req,
|
||||
context.res,
|
||||
getAuthOptions({})
|
||||
)
|
||||
const feedbackId = context.query.feedbackId?.toString() as string
|
||||
if (isNotDefined(session?.user))
|
||||
return {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { GetServerSidePropsContext } from 'next'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from './api/auth/[...nextauth]'
|
||||
import { getAuthOptions } from './api/auth/[...nextauth]'
|
||||
|
||||
export default function Page() {
|
||||
return null
|
||||
@ -9,7 +9,11 @@ export default function Page() {
|
||||
export const getServerSideProps = async (
|
||||
context: GetServerSidePropsContext
|
||||
) => {
|
||||
const session = await getServerSession(context.req, context.res, authOptions)
|
||||
const session = await getServerSession(
|
||||
context.req,
|
||||
context.res,
|
||||
getAuthOptions({})
|
||||
)
|
||||
if (!session?.user) {
|
||||
return {
|
||||
redirect: {
|
||||
|
14
packages/lib/getIp.ts
Normal file
14
packages/lib/getIp.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
|
||||
export const getIp = (req: NextApiRequest): string | undefined => {
|
||||
let ip = req.headers['x-real-ip'] as string | undefined
|
||||
if (!ip) {
|
||||
const forwardedFor = req.headers['x-forwarded-for']
|
||||
if (Array.isArray(forwardedFor)) {
|
||||
ip = forwardedFor.at(0)
|
||||
} else {
|
||||
ip = forwardedFor?.split(',').at(0)
|
||||
}
|
||||
}
|
||||
return ip
|
||||
}
|
@ -58,6 +58,7 @@ model User {
|
||||
CollaboratorsOnTypebots CollaboratorsOnTypebots[]
|
||||
workspaces MemberInWorkspace[]
|
||||
sessions Session[]
|
||||
bannedIps BannedIp[]
|
||||
}
|
||||
|
||||
model ApiToken {
|
||||
@ -202,6 +203,7 @@ model Typebot {
|
||||
isClosed Boolean @default(false)
|
||||
whatsAppCredentialsId String?
|
||||
riskLevel Int?
|
||||
bannedIps BannedIp[]
|
||||
|
||||
@@index([workspaceId])
|
||||
@@index([folderId])
|
||||
@ -361,6 +363,19 @@ model ThemeTemplate {
|
||||
@@index([workspaceId])
|
||||
}
|
||||
|
||||
model BannedIp {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
ip String @unique
|
||||
responsibleTypebot Typebot @relation(fields: [responsibleTypebotId], references: [id], onDelete: Restrict)
|
||||
responsibleTypebotId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
userId String
|
||||
|
||||
@@index([responsibleTypebotId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum WorkspaceRole {
|
||||
ADMIN
|
||||
MEMBER
|
||||
|
@ -0,0 +1,22 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "BannedIp" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ip" TEXT NOT NULL,
|
||||
"responsibleTypebotId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "BannedIp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "BannedIp_ip_key" ON "BannedIp"("ip");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Answer_storageUsed_idx" ON "Answer"("storageUsed");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BannedIp" ADD CONSTRAINT "BannedIp_responsibleTypebotId_fkey" FOREIGN KEY ("responsibleTypebotId") REFERENCES "Typebot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BannedIp" ADD CONSTRAINT "BannedIp_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
@ -54,6 +54,7 @@ model User {
|
||||
CollaboratorsOnTypebots CollaboratorsOnTypebots[]
|
||||
workspaces MemberInWorkspace[]
|
||||
sessions Session[]
|
||||
bannedIps BannedIp[]
|
||||
}
|
||||
|
||||
model ApiToken {
|
||||
@ -186,6 +187,7 @@ model Typebot {
|
||||
isClosed Boolean @default(false)
|
||||
whatsAppCredentialsId String?
|
||||
riskLevel Int?
|
||||
bannedIps BannedIp[]
|
||||
|
||||
@@index([workspaceId])
|
||||
@@index([isArchived, createdAt(sort: Desc)])
|
||||
@ -338,6 +340,16 @@ model ThemeTemplate {
|
||||
workspaceId String
|
||||
}
|
||||
|
||||
model BannedIp {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
ip String @unique
|
||||
responsibleTypebot Typebot @relation(fields: [responsibleTypebotId], references: [id], onDelete: Restrict)
|
||||
responsibleTypebotId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
userId String
|
||||
}
|
||||
|
||||
enum WorkspaceRole {
|
||||
ADMIN
|
||||
MEMBER
|
||||
|
Reference in New Issue
Block a user