Skip to content

Commit b20cf32

Browse files
authored
Merge pull request #444 from cloudflare/fix/rate-limit-identity-bypass
fix(security): key anonymous rate limit on IP, remove dead email-OTP …
2 parents 16d7119 + 0548ff3 commit b20cf32

11 files changed

Lines changed: 186 additions & 310 deletions

File tree

docs/llm.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4258,7 +4258,7 @@ Audit log for all authentication attempts.
42584258

42594259
### **verificationOtps Table**
42604260

4261-
Email verification codes (currently not actively used as users are auto-verified).
4261+
Email verification codes. The email-OTP verification flow has been removed (users are auto-verified on registration), so nothing reads or writes this table; the table is retained only to avoid a destructive migration.
42624262

42634263
**Fields:** email, otp (hashed), used, expiresAt (15 min)
42644264

src/lib/api-client.ts

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,34 +1235,6 @@ class ApiClient {
12351235
});
12361236
}
12371237

1238-
/**
1239-
* Verify email with OTP
1240-
*/
1241-
async verifyEmail(data: {
1242-
email: string;
1243-
otp: string;
1244-
}): Promise<ApiResponse<LoginResponseData>> {
1245-
return this.request<LoginResponseData>('/api/auth/verify-email', {
1246-
method: 'POST',
1247-
body: data,
1248-
});
1249-
}
1250-
1251-
/**
1252-
* Resend verification OTP
1253-
*/
1254-
async resendVerificationOtp(
1255-
email: string,
1256-
): Promise<ApiResponse<{ message: string }>> {
1257-
return this.request<{ message: string }>(
1258-
'/api/auth/resend-verification',
1259-
{
1260-
method: 'POST',
1261-
body: { email },
1262-
},
1263-
);
1264-
}
1265-
12661238
/**
12671239
* Get CSRF token
12681240
*/

worker/api/controllers/auth/authSchemas.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,6 @@ export const resetPasswordSchema = z.object({
7676

7777
export type ResetPasswordRequest = z.infer<typeof resetPasswordSchema>;
7878

79-
/**
80-
* Verify email schema
81-
*/
82-
export const verifyEmailSchema = z.object({
83-
token: z.string().min(1, 'Verification token is required')
84-
});
85-
86-
export type VerifyEmailRequest = z.infer<typeof verifyEmailSchema>;
87-
8879
/**
8980
* OAuth provider schema
9081
*/

worker/api/controllers/auth/controller.ts

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -794,77 +794,6 @@ export class AuthController extends BaseController {
794794
}
795795
}
796796

797-
/**
798-
* Verify email with OTP
799-
* POST /api/auth/verify-email
800-
*/
801-
static async verifyEmail(request: Request, env: Env, _ctx: ExecutionContext, _routeContext: RouteContext): Promise<Response> {
802-
try {
803-
const bodyResult = await AuthController.parseJsonBody<{ email: string; otp: string }>(request);
804-
if (!bodyResult.success) {
805-
return bodyResult.response!;
806-
}
807-
808-
const { email, otp } = bodyResult.data!;
809-
810-
if (!email || !otp) {
811-
return AuthController.createErrorResponse('Email and OTP are required', 400);
812-
}
813-
814-
const authService = new AuthService(env);
815-
const result = await authService.verifyEmailWithOtp(email, otp, request);
816-
817-
const response = AuthController.createSuccessResponse(
818-
formatAuthResponse(result.user, result.sessionId, result.expiresAt)
819-
);
820-
821-
setSecureAuthCookies(response, {
822-
accessToken: result.accessToken,
823-
accessTokenExpiry: SessionService.config.sessionTTL
824-
});
825-
826-
return response;
827-
} catch (error) {
828-
if (error instanceof SecurityError) {
829-
return AuthController.createErrorResponse(error.message, error.statusCode);
830-
}
831-
832-
return AuthController.handleError(error, 'verify email');
833-
}
834-
}
835-
836-
/**
837-
* Resend verification OTP
838-
* POST /api/auth/resend-verification
839-
*/
840-
static async resendVerificationOtp(request: Request, env: Env, _ctx: ExecutionContext, _routeContext: RouteContext): Promise<Response> {
841-
try {
842-
const bodyResult = await AuthController.parseJsonBody<{ email: string }>(request);
843-
if (!bodyResult.success) {
844-
return bodyResult.response!;
845-
}
846-
847-
const { email } = bodyResult.data!;
848-
849-
if (!email) {
850-
return AuthController.createErrorResponse('Email is required', 400);
851-
}
852-
853-
const authService = new AuthService(env);
854-
await authService.resendVerificationOtp(email);
855-
856-
return AuthController.createSuccessResponse({
857-
message: 'Verification code sent successfully'
858-
});
859-
} catch (error) {
860-
if (error instanceof SecurityError) {
861-
return AuthController.createErrorResponse(error.message, error.statusCode);
862-
}
863-
864-
return AuthController.handleError(error, 'resend verification OTP');
865-
}
866-
}
867-
868797
/**
869798
* Get CSRF token with proper expiration and rotation
870799
* GET /api/auth/csrf-token

worker/api/handlers/git-protocol.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,6 @@ async function verifyGitAccess(
106106
): Promise<{ hasAccess: boolean; appCreatedAt?: Date }> {
107107
logger.info('Verifying git access', { appId });
108108

109-
// Log all headers for debugging
110-
const headers: Record<string, string> = {};
111-
request.headers.forEach((value, key) => {
112-
headers[key] = key.toLowerCase().includes('auth') ? `${value.substring(0, 20)}...` : value;
113-
});
114-
logger.info('Request headers', { headers, url: request.url });
115-
116109
const appService = new AppService(env);
117110
const app = await appService.getAppDetails(appId);
118111

worker/api/routes/authRoutes.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ export function setupAuthRoutes(app: Hono<AppEnv>): void {
1919
authRouter.get('/providers', setAuthLevel(AuthConfig.public), adaptController(AuthController, AuthController.getAuthProviders));
2020
authRouter.post('/register', setAuthLevel(AuthConfig.public), adaptController(AuthController, AuthController.register));
2121
authRouter.post('/login', setAuthLevel(AuthConfig.public), adaptController(AuthController, AuthController.login));
22-
authRouter.post('/verify-email', setAuthLevel(AuthConfig.public), adaptController(AuthController, AuthController.verifyEmail));
23-
authRouter.post('/resend-verification', setAuthLevel(AuthConfig.public), adaptController(AuthController, AuthController.resendVerificationOtp));
2422
authRouter.get('/check', setAuthLevel(AuthConfig.public), adaptController(AuthController, AuthController.checkAuth));
2523

2624
// Protected routes (require authentication) - must come before dynamic OAuth routes

worker/database/services/AuthService.ts

Lines changed: 0 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -866,125 +866,6 @@ export class AuthService extends BaseService {
866866
}
867867
}
868868

869-
/**
870-
* Generate and store verification OTP for email
871-
*/
872-
private async generateAndStoreVerificationOtp(email: string): Promise<void> {
873-
const otp = Math.floor(100000 + Math.random() * 900000).toString(); // 6-digit OTP
874-
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes expiry
875-
876-
// Store OTP in database (you may need to create a verification_otps table)
877-
await this.database.insert(schema.verificationOtps).values({
878-
id: generateId(),
879-
email: email.toLowerCase(),
880-
otp: await this.passwordService.hash(otp), // Hash the OTP for security
881-
expiresAt,
882-
createdAt: new Date()
883-
});
884-
885-
// TODO: Send email with OTP (integrate with email service)
886-
logger.info('Verification OTP generated', { email, otp: otp.slice(0, 2) + '****' });
887-
}
888-
889-
/**
890-
* Verify email with OTP
891-
*/
892-
async verifyEmailWithOtp(email: string, otp: string, request: Request): Promise<AuthResult> {
893-
try {
894-
// Deployment-level admission gate (ALLOWED_EMAIL)
895-
enforceAllowedEmail(this.env, email, 'login');
896-
897-
// Find valid OTP
898-
const storedOtp = await this.database
899-
.select()
900-
.from(schema.verificationOtps)
901-
.where(
902-
and(
903-
eq(schema.verificationOtps.email, email.toLowerCase()),
904-
eq(schema.verificationOtps.used, false),
905-
sql`${schema.verificationOtps.expiresAt} > ${new Date()}`
906-
)
907-
)
908-
.orderBy(sql`${schema.verificationOtps.createdAt} DESC`)
909-
.get();
910-
911-
if (!storedOtp) {
912-
throw new SecurityError(
913-
SecurityErrorType.INVALID_INPUT,
914-
'Invalid or expired verification code',
915-
400
916-
);
917-
}
918-
919-
// Verify OTP
920-
const otpValid = await this.passwordService.verify(otp, storedOtp.otp);
921-
if (!otpValid) {
922-
throw new SecurityError(
923-
SecurityErrorType.INVALID_INPUT,
924-
'Invalid verification code',
925-
400
926-
);
927-
}
928-
929-
// Mark OTP as used
930-
await this.database
931-
.update(schema.verificationOtps)
932-
.set({ used: true, usedAt: new Date() })
933-
.where(eq(schema.verificationOtps.id, storedOtp.id));
934-
935-
// Find and verify the user
936-
const user = await this.database
937-
.select()
938-
.from(schema.users)
939-
.where(eq(schema.users.email, email.toLowerCase()))
940-
.get();
941-
942-
if (!user) {
943-
throw new SecurityError(
944-
SecurityErrorType.INVALID_INPUT,
945-
'User not found',
946-
404
947-
);
948-
}
949-
950-
// Update user as verified
951-
await this.database
952-
.update(schema.users)
953-
.set({ emailVerified: true, updatedAt: new Date() })
954-
.where(eq(schema.users.id, user.id));
955-
956-
// Create session for verified user
957-
const { accessToken, session } = await this.sessionService.createSession(
958-
user.id,
959-
request
960-
);
961-
962-
// Log successful verification
963-
await this.logAuthAttempt(email, 'email_verification', true, request);
964-
logger.info('Email verified successfully', { email, userId: user.id });
965-
966-
return {
967-
user: mapUserResponse({ ...user, emailVerified: true }),
968-
accessToken,
969-
sessionId: session.sessionId,
970-
expiresAt: session.expiresAt,
971-
};
972-
} catch (error) {
973-
await this.logAuthAttempt(email, 'email_verification', false, request);
974-
975-
if (error instanceof SecurityError) {
976-
throw error;
977-
}
978-
979-
logger.error('Email verification error', error);
980-
throw new SecurityError(
981-
SecurityErrorType.INVALID_INPUT,
982-
'Email verification failed',
983-
500
984-
);
985-
}
986-
}
987-
988869
/**
989870
* Get user for authentication (for middleware)
990871
*/
@@ -1120,60 +1001,4 @@ export class AuthService extends BaseService {
11201001
}
11211002
}
11221003

1123-
/**
1124-
* Resend verification OTP
1125-
*/
1126-
async resendVerificationOtp(email: string): Promise<void> {
1127-
try {
1128-
// Check if user exists and is unverified
1129-
const user = await this.database
1130-
.select()
1131-
.from(schema.users)
1132-
.where(eq(schema.users.email, email.toLowerCase()))
1133-
.get();
1134-
1135-
if (!user) {
1136-
throw new SecurityError(
1137-
SecurityErrorType.INVALID_INPUT,
1138-
'No account found with this email',
1139-
404
1140-
);
1141-
}
1142-
1143-
if (user.emailVerified) {
1144-
throw new SecurityError(
1145-
SecurityErrorType.INVALID_INPUT,
1146-
'Email is already verified',
1147-
400
1148-
);
1149-
}
1150-
1151-
// Invalidate existing OTPs
1152-
await this.database
1153-
.update(schema.verificationOtps)
1154-
.set({ used: true, usedAt: new Date() })
1155-
.where(
1156-
and(
1157-
eq(schema.verificationOtps.email, email.toLowerCase()),
1158-
eq(schema.verificationOtps.used, false)
1159-
)
1160-
);
1161-
1162-
// Generate new OTP
1163-
await this.generateAndStoreVerificationOtp(email.toLowerCase());
1164-
1165-
logger.info('Verification OTP resent', { email });
1166-
} catch (error) {
1167-
if (error instanceof SecurityError) {
1168-
throw error;
1169-
}
1170-
1171-
logger.error('Resend verification OTP error', error);
1172-
throw new SecurityError(
1173-
SecurityErrorType.INVALID_INPUT,
1174-
'Failed to resend verification code',
1175-
500
1176-
);
1177-
}
1178-
}
11791004
}

worker/services/rate-limit/rateLimits.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,47 @@ describe('RateLimitService.enforceLLMCallsRateLimit', () => {
102102
).resolves.toBeUndefined();
103103
});
104104
});
105+
106+
describe('RateLimitService.getRequestIdentifier', () => {
107+
function request(headers: Record<string, string>): Request {
108+
return new Request('https://example.com/api/foo', { headers });
109+
}
110+
111+
it('returns the same identifier for the same IP with different bearer tokens', async () => {
112+
const id1 = await RateLimitService.getRequestIdentifier(
113+
request({ 'CF-Connecting-IP': '1.2.3.4', Authorization: 'Bearer aaaa' }),
114+
);
115+
const id2 = await RateLimitService.getRequestIdentifier(
116+
request({ 'CF-Connecting-IP': '1.2.3.4', Authorization: 'Bearer bbbb' }),
117+
);
118+
119+
expect(id1).toBe('ip:1.2.3.4');
120+
expect(id1).toBe(id2);
121+
expect(id1.startsWith('token:')).toBe(false);
122+
});
123+
124+
it('returns the same identifier for the same IP with different token cookies', async () => {
125+
const id1 = await RateLimitService.getRequestIdentifier(
126+
request({ 'CF-Connecting-IP': '1.2.3.4', Cookie: 'accessToken=aaaa' }),
127+
);
128+
const id2 = await RateLimitService.getRequestIdentifier(
129+
request({ 'CF-Connecting-IP': '1.2.3.4', Cookie: 'accessToken=bbbb' }),
130+
);
131+
132+
expect(id1).toBe('ip:1.2.3.4');
133+
expect(id1).toBe(id2);
134+
});
135+
136+
it('returns different identifiers for different IPs', async () => {
137+
const id1 = await RateLimitService.getRequestIdentifier(request({ 'CF-Connecting-IP': '1.2.3.4' }));
138+
const id2 = await RateLimitService.getRequestIdentifier(request({ 'CF-Connecting-IP': '5.6.7.8' }));
139+
140+
expect(id1).not.toBe(id2);
141+
});
142+
143+
it('falls back to ip:unknown when no IP headers are present', async () => {
144+
const id = await RateLimitService.getRequestIdentifier(request({ Authorization: 'Bearer aaaa' }));
145+
146+
expect(id).toBe('ip:unknown');
147+
});
148+
});

0 commit comments

Comments
 (0)