Compare commits

...

3 Commits

Author SHA1 Message Date
Matti Nannt c93c35edfd ci(e2e): disable rate limiting and set CI environment variable
- Disable rate limiting for E2E tests to prevent test failures caused by rate limits
- Set CI environment variable for Playwright to optimize test execution in CI environment
- Apply to both Azure and local E2E test runs
2025-10-04 08:26:17 +02:00
Victor Hugo dos Santos b67177ba55 Merge commit from fork
* fix(auth): enhance password validation and rate limiting for login attempts

- Added password length validation to prevent CPU DoS attacks, limiting to 128 characters.
- Implemented constant-time password verification to mitigate timing attacks.
- Adjusted rate limit for login attempts from 30 to 10 per 15 minutes for improved security.
- Updated login form validation to reflect new password length constraints.
- Introduced constants for authentication endpoints in the API.

* fixed sample size for timing test

* password validation messages

---------

Co-authored-by: Your Name <you@example.com>
2025-10-02 11:09:28 +02:00
Johannes 6cf1f49c8e docs: add tag docs (#6640) 2025-10-02 01:47:31 -07:00
10 changed files with 596 additions and 17 deletions
+10 -1
View File
@@ -181,6 +181,12 @@ jobs:
fi
echo "License key length: ${#LICENSE_KEY}"
- name: Disable rate limiting for E2E tests
run: |
echo "RATE_LIMITING_DISABLED=1" >> .env
echo "Rate limiting disabled for E2E tests"
shell: bash
- name: Run App
run: |
echo "Starting app with enterprise license..."
@@ -222,11 +228,14 @@ jobs:
if: env.AZURE_ENABLED == 'true'
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
CI: true
run: |
pnpm test-e2e:azure
- name: Run E2E Tests (Local)
if: env.AZURE_ENABLED == 'false'
env:
CI: true
run: |
pnpm test:e2e
@@ -245,4 +254,4 @@ jobs:
- name: Output App Logs
if: failure()
run: cat app.log
run: cat app.log
@@ -120,7 +120,7 @@ describe("PasswordConfirmationModal", () => {
const confirmButton = screen.getByText("common.confirm");
await user.click(confirmButton);
expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument();
expect(screen.getByText("Password must be at least 8 characters long")).toBeInTheDocument();
});
test("handles cancel button click and resets form", async () => {
+19 -2
View File
@@ -66,8 +66,21 @@ export const authOptions: NextAuthOptions = {
throw new Error("Invalid credentials");
}
// Validate password length to prevent CPU DoS attacks
// bcrypt processes passwords up to 72 bytes, but we limit to 128 characters for security
if (credentials.password && credentials.password.length > 128) {
if (await shouldLogAuthFailure(identifier)) {
logAuthAttempt("password_too_long", "credentials", "password_validation", UNKNOWN_DATA, credentials?.email);
}
throw new Error("Invalid credentials");
}
// Use a control hash when user doesn't exist to maintain constant timing.
const controlHash = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
let user;
try {
// Perform database lookup
user = await prisma.user.findUnique({
where: {
email: credentials?.email,
@@ -79,6 +92,12 @@ export const authOptions: NextAuthOptions = {
throw Error("Internal server error. Please try again later");
}
// Always perform password verification to maintain constant timing. This is important to prevent timing attacks for user enumeration.
// Use actual hash if user exists, control hash if user doesn't exist
const hashToVerify = user?.password || controlHash;
const isValid = await verifyPassword(credentials.password, hashToVerify);
// Now check all conditions after constant-time operations are complete
if (!user) {
if (await shouldLogAuthFailure(identifier)) {
logAuthAttempt("user_not_found", "credentials", "user_lookup", UNKNOWN_DATA, credentials?.email);
@@ -96,8 +115,6 @@ export const authOptions: NextAuthOptions = {
throw new Error("Your account is currently inactive. Please contact the organization admin.");
}
const isValid = await verifyPassword(credentials.password, user.password);
if (!isValid) {
if (await shouldLogAuthFailure(user.email)) {
logAuthAttempt("invalid_password", "credentials", "password_validation", user.id, user.email);
@@ -1,5 +1,14 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { signIn } from "next-auth/react";
import Link from "next/dist/client/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { z } from "zod";
import { cn } from "@/lib/cn";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
@@ -10,19 +19,13 @@ import { TwoFactorBackup } from "@/modules/ee/two-factor-auth/components/two-fac
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { signIn } from "next-auth/react";
import Link from "next/dist/client/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { z } from "zod";
const ZLoginForm = z.object({
email: z.string().email(),
password: z.string().min(8),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters long" })
.max(128, { message: "Password must be 128 characters or less" }),
totpCode: z.string().optional(),
backupCode: z.string().optional(),
});
@@ -1,7 +1,7 @@
export const rateLimitConfigs = {
// Authentication endpoints - stricter limits for security
auth: {
login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" }, // 30 per 15 minutes
login: { interval: 900, allowedPerInterval: 10, namespace: "auth:login" }, // 10 per 15 minutes
signup: { interval: 3600, allowedPerInterval: 30, namespace: "auth:signup" }, // 30 per hour
forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" }, // 5 per hour
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" }, // 10 per hour
@@ -0,0 +1,402 @@
import { expect } from "@playwright/test";
import { logger } from "@formbricks/logger";
import { test } from "../../lib/fixtures";
// Authentication endpoints are hardcoded to avoid import issues
test.describe("Authentication Security Tests - Vulnerability Prevention", () => {
let csrfToken: string;
let testUser: { email: string; password: string };
test.beforeEach(async ({ request, users }) => {
// Get CSRF token for authentication requests
const csrfResponse = await request.get("/api/auth/csrf");
const csrfData = await csrfResponse.json();
csrfToken = csrfData.csrfToken;
// Create a test user for "existing user" scenarios with unique email
const uniqueId = Date.now() + Math.random();
const userName = "Security Test User";
const userEmail = `security-test-${uniqueId}@example.com`;
await users.create({
name: userName,
email: userEmail,
});
testUser = {
email: userEmail,
password: userName, // The fixture uses the name as password
};
});
test.describe("DoS Protection - Password Length Limits", () => {
test("should handle extremely long passwords without crashing", async ({ request }) => {
const email = "nonexistent-dos-test@example.com"; // Use non-existent email for DoS test
const extremelyLongPassword = "A".repeat(50000); // 50,000 characters
const start = Date.now();
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: email,
password: extremelyLongPassword,
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const responseTime = Date.now() - start;
// Should not crash the server (no 500 errors)
expect(response.status()).not.toBe(500);
// Should handle gracefully
expect([200, 400, 401, 429]).toContain(response.status());
logger.info(
`Extremely long password (50k chars) processing time: ${responseTime}ms, status: ${response.status()}`
);
// Verify the security fix is working: long passwords should be rejected quickly
// In production, this should be much faster, but test environment has overhead
if (responseTime < 5000) {
logger.info("✅ Long password rejected quickly - DoS protection working");
} else {
logger.warn("⚠️ Long password took longer than expected - check DoS protection");
}
});
test("should handle password at 128 character limit", async ({ request }) => {
const email = "nonexistent-limit-test@example.com"; // Use non-existent email for limit test
const maxLengthPassword = "A".repeat(128); // Exactly at the 128 character limit
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: email,
password: maxLengthPassword,
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
// Should process normally (not rejected for length)
expect(response.status()).not.toBe(500);
expect([200, 400, 401, 429]).toContain(response.status());
logger.info(`Max length password (128 chars) status: ${response.status()}`);
});
test("should reject passwords over 128 characters", async ({ request }) => {
const email = "nonexistent-overlimit-test@example.com"; // Use non-existent email for over-limit test
const overLimitPassword = "A".repeat(10000); // 10,000 characters (over limit)
const start = Date.now();
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: email,
password: overLimitPassword,
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const responseTime = Date.now() - start;
// Should not crash
expect(response.status()).not.toBe(500);
logger.info(
`Over-limit password (10k chars) processing time: ${responseTime}ms, status: ${response.status()}`
);
// The key security test: verify it doesn't take exponentially longer than shorter passwords
// This tests the DoS protection is working
});
});
test.describe("Timing Attack Prevention - User Enumeration Protection", () => {
test("should not reveal user existence through response timing differences", async ({ request }) => {
// Test multiple attempts to get reliable timing measurements
const attempts = 50;
const nonExistentTimes: number[] = [];
const existingUserTimes: number[] = [];
// Test non-existent user timing (multiple attempts for statistical reliability)
for (let i = 0; i < attempts; i++) {
const start = process.hrtime.bigint();
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: `nonexistent-timing-${i}@example.com`,
password: "somepassword",
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const end = process.hrtime.bigint();
const responseTime = Number(end - start) / 1000000; // Convert to milliseconds
nonExistentTimes.push(responseTime);
expect(response.status()).not.toBe(500);
}
// Test existing user with wrong password timing (multiple attempts)
for (let i = 0; i < attempts; i++) {
const start = process.hrtime.bigint();
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: testUser.email,
password: "wrongpassword123",
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const end = process.hrtime.bigint();
const responseTime = Number(end - start) / 1000000; // Convert to milliseconds
existingUserTimes.push(responseTime);
expect(response.status()).not.toBe(500);
}
// Calculate averages
const avgNonExistent = nonExistentTimes.reduce((a, b) => a + b, 0) / nonExistentTimes.length;
const avgExisting = existingUserTimes.reduce((a, b) => a + b, 0) / existingUserTimes.length;
// Calculate the timing difference percentage
const timingDifference = Math.abs(avgExisting - avgNonExistent);
const timingDifferencePercent = (timingDifference / Math.max(avgExisting, avgNonExistent)) * 100;
logger.info(
`Non-existent user avg: ${avgNonExistent.toFixed(2)}ms (${nonExistentTimes.map((t) => t.toFixed(0)).join(", ")})`
);
logger.info(
`Existing user avg: ${avgExisting.toFixed(2)}ms (${existingUserTimes.map((t) => t.toFixed(0)).join(", ")})`
);
logger.info(
`Timing difference: ${timingDifference.toFixed(2)}ms (${timingDifferencePercent.toFixed(1)}%)`
);
// CRITICAL SECURITY TEST: Timing difference should be minimal
// A large timing difference could allow attackers to enumerate users
// Allow up to 20% difference to account for network/system variance
if (timingDifferencePercent > 20) {
logger.warn(
`⚠️ SECURITY RISK: Timing difference of ${timingDifferencePercent.toFixed(1)}% could allow user enumeration!`
);
logger.warn(`⚠️ Consider implementing constant-time authentication to prevent timing attacks`);
} else {
logger.info(
`✅ Timing attack protection: Only ${timingDifferencePercent.toFixed(1)}% difference between scenarios`
);
}
// Fail the test if timing difference exceeds our security threshold
expect(timingDifferencePercent).toBeLessThan(20); // Fail at our actual security threshold
});
test("should return consistent status codes regardless of user existence", async ({ request }) => {
const scenarios = [
{
email: "nonexistent-status@example.com",
password: "testpassword",
description: "non-existent user",
},
{ email: testUser.email, password: "wrongpassword", description: "existing user, wrong password" },
];
const results: { scenario: string; status: number }[] = [];
for (const scenario of scenarios) {
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: scenario.email,
password: scenario.password,
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
results.push({
scenario: scenario.description,
status: response.status(),
});
expect(response.status()).not.toBe(500);
}
// Log results
results.forEach(({ scenario, status }) => {
logger.info(`Status test - ${scenario}: ${status}`);
});
// CRITICAL: Both scenarios should return the same status code
// Different status codes could reveal user existence
const statuses = results.map((r) => r.status);
const uniqueStatuses = [...new Set(statuses)];
if (uniqueStatuses.length > 1) {
logger.warn(
`⚠️ SECURITY RISK: Different status codes (${uniqueStatuses.join(", ")}) could allow user enumeration!`
);
} else {
logger.info(`✅ Status code consistency: Both scenarios return ${statuses[0]}`);
}
expect(uniqueStatuses.length).toBe(1);
});
});
test.describe("Security Headers and Response Safety", () => {
test("should include security headers in responses", async ({ request }) => {
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: "nonexistent-headers-test@example.com",
password: "testpassword",
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
// Check for important security headers
const headers = response.headers();
// These headers should be present for security
expect(headers["x-frame-options"]).toBeDefined();
expect(headers["x-content-type-options"]).toBe("nosniff");
if (headers["strict-transport-security"]) {
expect(headers["strict-transport-security"]).toContain("max-age");
}
if (headers["content-security-policy"]) {
expect(headers["content-security-policy"]).toContain("default-src");
}
logger.info("✅ Security headers present in authentication responses");
});
test("should not expose sensitive information in error responses", async ({ request }) => {
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: "nonexistent-disclosure-test@example.com",
password: "A".repeat(10000), // Trigger long password handling
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const responseBody = await response.text();
// Log the actual response for debugging
logger.info(`Response status: ${response.status()}`);
logger.info(`Response body (first 500 chars): ${responseBody.substring(0, 500)}`);
// Check if this is an HTML response (which indicates NextAuth.js is returning a page instead of API response)
const isHtmlResponse =
responseBody.trim().startsWith("<!DOCTYPE html>") || responseBody.includes("<html");
if (isHtmlResponse) {
logger.info(
"✅ NextAuth.js returned HTML page instead of API response - this is expected behavior for security"
);
logger.info("✅ No sensitive technical information exposed in authentication API");
return; // Skip the sensitive information check for HTML responses
}
// Only check for sensitive information in actual API responses (JSON/text)
const sensitiveTerms = [
"password_too_long",
"bcrypt",
"hash",
"redis",
"database",
"prisma",
"stack trace",
"rate limit exceeded",
"authentication failed",
"sql",
"query",
"connection timeout",
"internal error",
];
let foundSensitiveInfo = false;
const foundTerms: string[] = [];
for (const term of sensitiveTerms) {
if (responseBody.toLowerCase().includes(term.toLowerCase())) {
foundSensitiveInfo = true;
foundTerms.push(term);
logger.warn(`Found "${term}" in response`);
}
}
if (foundSensitiveInfo) {
logger.warn(`⚠️ Found sensitive information in response: ${foundTerms.join(", ")}`);
logger.warn(`Full response body: ${responseBody}`);
} else {
logger.info("✅ No sensitive technical information exposed in error responses");
}
// Don't fail the test for generic web responses, only for actual security leaks
expect(foundSensitiveInfo).toBe(false);
});
test("should handle malformed requests gracefully", async ({ request }) => {
// Test with missing CSRF token
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: "nonexistent-malformed-test@example.com",
password: "testpassword",
redirect: "false",
json: "true",
// Missing csrfToken
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
// Should handle gracefully, not crash
expect(response.status()).not.toBe(500);
expect([200, 400, 401, 403, 429]).toContain(response.status());
logger.info(`✅ Malformed request handled gracefully: status ${response.status()}`);
});
});
});
+4
View File
@@ -5,6 +5,10 @@ export const ROLES_API_URL = `/api/v2/roles`;
export const ME_API_URL = `/api/v2/me`;
export const HEALTH_API_URL = `/api/v2/health`;
// Authentication endpoints
export const AUTH_CALLBACK_URL = `/api/auth/callback/credentials`;
export const AUTH_CSRF_URL = `/api/auth/csrf`;
export const TEAMS_API_URL = (organizationId: string) => `/api/v2/organizations/${organizationId}/teams`;
export const PROJECT_TEAMS_API_URL = (organizationId: string) =>
`/api/v2/organizations/${organizationId}/project-teams`;
+2 -1
View File
@@ -79,7 +79,8 @@
"xm-and-surveys/surveys/general-features/hide-back-button",
"xm-and-surveys/surveys/general-features/email-followups",
"xm-and-surveys/surveys/general-features/quota-management",
"xm-and-surveys/surveys/general-features/spam-protection"
"xm-and-surveys/surveys/general-features/spam-protection",
"xm-and-surveys/surveys/general-features/tags"
]
},
{
@@ -0,0 +1,143 @@
---
title: "Tags"
description: "Organize and categorize survey responses to easily filter, analyze, and manage your data."
icon: "tag"
---
## What are Tags?
Tags are labels that you can apply to individual survey responses. They allow you to:
- Categorize responses by topic, sentiment, or any custom criteria
- Filter responses to find specific subsets of data
- Track and organize feedback across multiple surveys
- Simplify analysis and reporting workflows
Tags are environment-specific, meaning each environment maintains its own set of tags.
## Add tags to responses
<Steps>
<Step title="Navigate to responses">
Go to the **Responses** tab of your survey.
</Step>
<Step title="Open a response">
Click on any response card to view the full response details.
</Step>
<Step title="Add a tag">
At the bottom of the response card, click the **Add Tag** button.
</Step>
<Step title="Select or create a tag">
- Select an existing tag from the dropdown list, or
- Type a new tag name and click **+ Add [tag name]** to create a new tag
</Step>
</Steps>
The tag will be immediately applied to the response. You can add multiple tags to a single response.
## Remove tags from responses
To remove a tag from a response:
1. Open the response card
2. Click the **X** icon on the tag you want to remove
The tag will be removed from the response immediately.
## Manage tags
Access the tag management page to view and organize all tags in your environment.
<Steps>
<Step title="Navigate to Configuration">
Click on **Project Configuration** > **Tags**.
</Step>
<Step title="View all tags">
You'll see a list of all tags in your environment with their usage count showing how many responses have each tag applied.
</Step>
</Steps>
### Edit tag names
1. In the tag management page, click on the tag name field
2. Edit the name directly
3. Click outside the field or press Enter to save
<Note>
Tag names must be unique within an environment. If you try to use an existing tag name, you'll receive an error.
</Note>
### Merge tags
Merging tags is useful when you have duplicate or similar tags that you want to consolidate.
1. In the tag management page, find the tag you want to merge
2. Click the **Merge into** dropdown
3. Select the destination tag
4. Confirm the merge
All responses tagged with the original tag will be updated to use the destination tag, and the original tag will be deleted.
### Delete tags
1. In the tag management page, find the tag you want to delete
2. Click the **Delete** button
3. Confirm the deletion
<Warning>
Deleting a tag will remove it from all responses. This action cannot be undone.
</Warning>
## Filter responses by tags
Use tags to filter and find specific responses in your survey analysis.
<Steps>
<Step title="Open filters">
In the **Responses** tab, click the **Filter** button.
</Step>
<Step title="Select tag filter">
Scroll to the **Tags** section and select a tag from the dropdown.
</Step>
<Step title="Choose filter type">
Choose whether to filter by:
- **Applied**: Show only responses that have this tag
- **Not Applied**: Show only responses that don't have this tag
</Step>
<Step title="View filtered results">
The response list will update to show only responses matching your tag filter.
</Step>
</Steps>
You can combine tag filters with other filters (questions, attributes, metadata) to create complex filter criteria.
## Use cases
Here are some common ways to use tags:
- **Sentiment tracking**: Tag responses as "positive", "negative", or "neutral"
- **Follow-up needed**: Mark responses that require action with a "follow-up" tag
- **Feature requests**: Categorize feedback by product area or feature
- **Customer segments**: Tag responses by customer type, industry, or plan level
- **Priority levels**: Mark critical issues with "urgent" or "high-priority" tags
- **Review status**: Track which responses have been reviewed with "reviewed" or "pending" tags
## Permissions
Tag management requires appropriate permissions:
- **View tags**: All users with access to the environment can view tags on responses
- **Add/remove tags on responses**: Users with read-write access can apply and remove tags
- **Manage tags** (create, edit, merge, delete): Users with project team read-write permission or organization owner/manager roles
---
**Need help?** [Reach out in Github Discussions](https://github.com/formbricks/formbricks/discussions)
+1 -1
View File
@@ -31,7 +31,7 @@ export type TUserEmail = z.infer<typeof ZUserEmail>;
export const ZUserPassword = z
.string()
.min(8)
.min(8, { message: "Password must be at least 8 characters long" })
.max(128, { message: "Password must be 128 characters or less" })
.regex(/^(?=.*[A-Z])(?=.*\d).*$/);