Compare commits

..

9 Commits

Author SHA1 Message Date
Matti Nannt
d49517be91 fix(ci): backport release tag validation fix to release/4.0 (#6609) 2025-09-26 09:41:06 +02:00
Victor Hugo dos Santos
7aedb73378 chore: backport jwt and script fix (#6607)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2025-09-25 11:53:45 -03:00
Dhruwang Jariwala
4112722a88 fix: replace button with div in IdBadge to prevent hydration issues (backport) (#6602) 2025-09-25 10:42:29 -03:00
Piyush Gupta
0eddeb46c1 fix: logicfallback cleanup backport (#6603) 2025-09-25 18:49:55 +05:30
Piyush Gupta
774f45b109 chore: reverted translations 2025-09-25 18:21:51 +05:30
Piyush Gupta
3c65c002bb chore: reverted translations 2025-09-25 18:19:28 +05:30
Piyush Gupta
65539e85df fix: logicFallback cleanup backport 2025-09-25 17:42:55 +05:30
Anshuman Pandey
91dab12a81 fix: backports migration script changes (#6583) 2025-09-22 15:30:36 +02:00
Matti Nannt
1c5244e030 fix: s3 storage configured flag fails on minimum setup (#6573) 2025-09-22 09:11:58 +02:00
16 changed files with 1651 additions and 354 deletions

View File

@@ -4,7 +4,7 @@ on:
workflow_call:
inputs:
release_tag:
description: "The release tag name (e.g., v1.2.3)"
description: "The release tag name (e.g., 1.2.3)"
required: true
type: string
commit_sha:
@@ -53,8 +53,8 @@ jobs:
set -euo pipefail
# Validate release tag format
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format. Expected format: v1.2.3, v1.2.3-alpha"
if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format. Expected format: 1.2.3, 1.2.3-alpha"
echo "Provided: $RELEASE_TAG"
exit 1
fi

View File

@@ -117,7 +117,7 @@ export const MAX_FILE_UPLOAD_SIZES = {
// Storage is considered configured if we have the minimum required settings:
// - S3_REGION and S3_BUCKET_NAME are always required
// - S3_ACCESS_KEY and S3_SECRET_KEY are optional (for IAM role-based authentication)
export const IS_STORAGE_CONFIGURED = Boolean(S3_REGION && S3_BUCKET_NAME);
export const IS_STORAGE_CONFIGURED = Boolean(S3_BUCKET_NAME);
// Colors for Survey Bg
export const SURVEY_BG_COLORS = [

View File

@@ -1,6 +1,7 @@
import { env } from "@/lib/env";
import jwt from "jsonwebtoken";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import * as crypto from "@/lib/crypto";
import {
createEmailChangeToken,
createEmailToken,
@@ -14,12 +15,69 @@ import {
verifyTokenForLinkSurvey,
} from "./jwt";
const TEST_ENCRYPTION_KEY = "0".repeat(32); // 32-byte key for AES-256-GCM
const TEST_NEXTAUTH_SECRET = "test-nextauth-secret";
const DIFFERENT_SECRET = "different-secret";
// Error message constants
const NEXTAUTH_SECRET_ERROR = "NEXTAUTH_SECRET is not set";
const ENCRYPTION_KEY_ERROR = "ENCRYPTION_KEY is not set";
// Helper function to test error cases for missing secrets/keys
const testMissingSecretsError = async (
testFn: (...args: any[]) => any,
args: any[],
options: {
testNextAuthSecret?: boolean;
testEncryptionKey?: boolean;
isAsync?: boolean;
} = {}
) => {
const { testNextAuthSecret = true, testEncryptionKey = true, isAsync = false } = options;
if (testNextAuthSecret) {
const constants = await import("@/lib/constants");
const originalSecret = (constants as any).NEXTAUTH_SECRET;
(constants as any).NEXTAUTH_SECRET = undefined;
if (isAsync) {
await expect(testFn(...args)).rejects.toThrow(NEXTAUTH_SECRET_ERROR);
} else {
expect(() => testFn(...args)).toThrow(NEXTAUTH_SECRET_ERROR);
}
// Restore
(constants as any).NEXTAUTH_SECRET = originalSecret;
}
if (testEncryptionKey) {
const constants = await import("@/lib/constants");
const originalKey = (constants as any).ENCRYPTION_KEY;
(constants as any).ENCRYPTION_KEY = undefined;
if (isAsync) {
await expect(testFn(...args)).rejects.toThrow(ENCRYPTION_KEY_ERROR);
} else {
expect(() => testFn(...args)).toThrow(ENCRYPTION_KEY_ERROR);
}
// Restore
(constants as any).ENCRYPTION_KEY = originalKey;
}
};
// Mock environment variables
vi.mock("@/lib/env", () => ({
env: {
ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM
ENCRYPTION_KEY: "0".repeat(32),
NEXTAUTH_SECRET: "test-nextauth-secret",
} as typeof env,
},
}));
// Mock constants
vi.mock("@/lib/constants", () => ({
NEXTAUTH_SECRET: "test-nextauth-secret",
ENCRYPTION_KEY: "0".repeat(32),
}));
// Mock prisma
@@ -31,22 +89,65 @@ vi.mock("@formbricks/database", () => ({
},
}));
describe("JWT Functions", () => {
// Mock logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
describe("JWT Functions - Comprehensive Security Tests", () => {
const mockUser = {
id: "test-user-id",
email: "test@example.com",
};
let mockSymmetricEncrypt: any;
let mockSymmetricDecrypt: any;
beforeEach(() => {
vi.clearAllMocks();
// Setup default crypto mocks
mockSymmetricEncrypt = vi
.spyOn(crypto, "symmetricEncrypt")
.mockImplementation((text: string) => `encrypted_${text}`);
mockSymmetricDecrypt = vi
.spyOn(crypto, "symmetricDecrypt")
.mockImplementation((encryptedText: string) => encryptedText.replace("encrypted_", ""));
(prisma.user.findUnique as any).mockResolvedValue(mockUser);
});
describe("createToken", () => {
test("should create a valid token", () => {
const token = createToken(mockUser.id, mockUser.email);
test("should create a valid token with encrypted user ID", () => {
const token = createToken(mockUser.id);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
});
test("should accept custom options", () => {
const customOptions = { expiresIn: "1h" };
const token = createToken(mockUser.id, customOptions);
expect(token).toBeDefined();
// Verify the token contains the expected expiration
const decoded = jwt.decode(token) as any;
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
// Should expire in approximately 1 hour (3600 seconds)
expect(decoded.exp - decoded.iat).toBe(3600);
});
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
await testMissingSecretsError(createToken, [mockUser.id], {
testNextAuthSecret: true,
testEncryptionKey: false,
});
});
});
@@ -56,6 +157,18 @@ describe("JWT Functions", () => {
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
});
test("should include surveyId in payload", () => {
const surveyId = "test-survey-id";
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
const decoded = jwt.decode(token) as any;
expect(decoded.surveyId).toBe(surveyId);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createTokenForLinkSurvey, ["survey-id", mockUser.email]);
});
});
@@ -64,24 +177,30 @@ describe("JWT Functions", () => {
const token = createEmailToken(mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
});
test("should throw error if NEXTAUTH_SECRET is not set", () => {
const originalSecret = env.NEXTAUTH_SECRET;
try {
(env as any).NEXTAUTH_SECRET = undefined;
expect(() => createEmailToken(mockUser.email)).toThrow("NEXTAUTH_SECRET is not set");
} finally {
(env as any).NEXTAUTH_SECRET = originalSecret;
}
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createEmailToken, [mockUser.email]);
});
});
describe("getEmailFromEmailToken", () => {
test("should extract email from valid token", () => {
const token = createEmailToken(mockUser.email);
const extractedEmail = getEmailFromEmailToken(token);
expect(extractedEmail).toBe(mockUser.email);
describe("createEmailChangeToken", () => {
test("should create a valid email change token with 1 day expiration", () => {
const token = createEmailChangeToken(mockUser.id, mockUser.email);
expect(token).toBeDefined();
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
const decoded = jwt.decode(token) as any;
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
// Should expire in approximately 1 day (86400 seconds)
expect(decoded.exp - decoded.iat).toBe(86400);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createEmailChangeToken, [mockUser.id, mockUser.email]);
});
});
@@ -91,6 +210,50 @@ describe("JWT Functions", () => {
const token = createInviteToken(inviteId, mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(inviteId, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
});
test("should accept custom options", () => {
const inviteId = "test-invite-id";
const customOptions = { expiresIn: "24h" };
const token = createInviteToken(inviteId, mockUser.email, customOptions);
expect(token).toBeDefined();
const decoded = jwt.decode(token) as any;
expect(decoded.exp).toBeDefined();
expect(decoded.iat).toBeDefined();
// Should expire in approximately 24 hours (86400 seconds)
expect(decoded.exp - decoded.iat).toBe(86400);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(createInviteToken, ["invite-id", mockUser.email]);
});
});
describe("getEmailFromEmailToken", () => {
test("should extract email from valid token", () => {
const token = createEmailToken(mockUser.email);
const extractedEmail = getEmailFromEmailToken(token);
expect(extractedEmail).toBe(mockUser.email);
expect(mockSymmetricDecrypt).toHaveBeenCalledWith(`encrypted_${mockUser.email}`, TEST_ENCRYPTION_KEY);
});
test("should fall back to original email if decryption fails", () => {
mockSymmetricDecrypt.mockImplementationOnce(() => {
throw new Error("Decryption failed");
});
// Create token manually with unencrypted email for legacy compatibility
const legacyToken = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
const extractedEmail = getEmailFromEmailToken(legacyToken);
expect(extractedEmail).toBe(mockUser.email);
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
await testMissingSecretsError(getEmailFromEmailToken, [token]);
});
});
@@ -106,23 +269,194 @@ describe("JWT Functions", () => {
const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id");
expect(result).toBeNull();
});
test("should return null if NEXTAUTH_SECRET is not set", async () => {
const constants = await import("@/lib/constants");
const originalSecret = (constants as any).NEXTAUTH_SECRET;
(constants as any).NEXTAUTH_SECRET = undefined;
const result = verifyTokenForLinkSurvey("any-token", "test-survey-id");
expect(result).toBeNull();
// Restore
(constants as any).NEXTAUTH_SECRET = originalSecret;
});
test("should return null if surveyId doesn't match", () => {
const surveyId = "test-survey-id";
const differentSurveyId = "different-survey-id";
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
const result = verifyTokenForLinkSurvey(token, differentSurveyId);
expect(result).toBeNull();
});
test("should return null if email is missing from payload", () => {
const tokenWithoutEmail = jwt.sign({ surveyId: "test-survey-id" }, TEST_NEXTAUTH_SECRET);
const result = verifyTokenForLinkSurvey(tokenWithoutEmail, "test-survey-id");
expect(result).toBeNull();
});
test("should fall back to original email if decryption fails", () => {
mockSymmetricDecrypt.mockImplementationOnce(() => {
throw new Error("Decryption failed");
});
// Create legacy token with unencrypted email
const legacyToken = jwt.sign(
{
email: mockUser.email,
surveyId: "test-survey-id",
},
TEST_NEXTAUTH_SECRET
);
const result = verifyTokenForLinkSurvey(legacyToken, "test-survey-id");
expect(result).toBe(mockUser.email);
});
test("should fall back to original email if ENCRYPTION_KEY is not set", async () => {
const constants = await import("@/lib/constants");
const originalKey = (constants as any).ENCRYPTION_KEY;
(constants as any).ENCRYPTION_KEY = undefined;
// Create a token with unencrypted email (as it would be if ENCRYPTION_KEY was not set during creation)
const token = jwt.sign(
{
email: mockUser.email,
surveyId: "survey-id",
},
TEST_NEXTAUTH_SECRET
);
const result = verifyTokenForLinkSurvey(token, "survey-id");
expect(result).toBe(mockUser.email);
// Restore
(constants as any).ENCRYPTION_KEY = originalKey;
});
test("should verify legacy survey tokens with surveyId-based secret", async () => {
const surveyId = "test-survey-id";
// Create legacy token with old format (NEXTAUTH_SECRET + surveyId)
const legacyToken = jwt.sign({ email: `encrypted_${mockUser.email}` }, TEST_NEXTAUTH_SECRET + surveyId);
const result = verifyTokenForLinkSurvey(legacyToken, surveyId);
expect(result).toBe(mockUser.email);
});
test("should reject survey tokens that fail both new and legacy verification", async () => {
const surveyId = "test-survey-id";
const invalidToken = jwt.sign({ email: "encrypted_test@example.com" }, "wrong-secret");
const result = verifyTokenForLinkSurvey(invalidToken, surveyId);
expect(result).toBeNull();
// Verify error logging
const { logger } = await import("@formbricks/logger");
expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Survey link token verification failed");
});
test("should reject legacy survey tokens for wrong survey", () => {
const correctSurveyId = "correct-survey-id";
const wrongSurveyId = "wrong-survey-id";
// Create legacy token for one survey
const legacyToken = jwt.sign(
{ email: `encrypted_${mockUser.email}` },
TEST_NEXTAUTH_SECRET + correctSurveyId
);
// Try to verify with different survey ID
const result = verifyTokenForLinkSurvey(legacyToken, wrongSurveyId);
expect(result).toBeNull();
});
});
describe("verifyToken", () => {
test("should verify valid token", async () => {
const token = createToken(mockUser.id, mockUser.email);
const token = createToken(mockUser.id);
const verified = await verifyToken(token);
expect(verified).toEqual({
id: mockUser.id,
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
});
});
test("should throw error if user not found", async () => {
(prisma.user.findUnique as any).mockResolvedValue(null);
const token = createToken(mockUser.id, mockUser.email);
const token = createToken(mockUser.id);
await expect(verifyToken(token)).rejects.toThrow("User not found");
});
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
await testMissingSecretsError(verifyToken, ["any-token"], {
testNextAuthSecret: true,
testEncryptionKey: false,
isAsync: true,
});
});
test("should throw error for invalid token signature", async () => {
const invalidToken = jwt.sign({ id: "test-id" }, DIFFERENT_SECRET);
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
});
test("should throw error if token payload is missing id", async () => {
const tokenWithoutId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
await expect(verifyToken(tokenWithoutId)).rejects.toThrow("Invalid token");
});
test("should return raw id from payload", async () => {
// Create token with unencrypted id
const token = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
const verified = await verifyToken(token);
expect(verified).toEqual({
id: mockUser.id, // Returns the raw ID from payload
email: mockUser.email,
});
});
test("should verify legacy tokens with email-based secret", async () => {
// Create legacy token with old format (NEXTAUTH_SECRET + userEmail)
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
const verified = await verifyToken(legacyToken);
expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
});
});
test("should prioritize new tokens over legacy tokens", async () => {
// Create both new and legacy tokens for the same user
const newToken = createToken(mockUser.id);
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
// New token should verify without triggering legacy path
const verifiedNew = await verifyToken(newToken);
expect(verifiedNew.id).toBe(mockUser.id); // Returns decrypted user ID
// Legacy token should trigger legacy path
const verifiedLegacy = await verifyToken(legacyToken);
expect(verifiedLegacy.id).toBe(mockUser.id); // Returns decrypted user ID
});
test("should reject tokens that fail both new and legacy verification", async () => {
const invalidToken = jwt.sign({ id: "encrypted_test-id" }, "wrong-secret");
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
// Verify both methods were attempted
const { logger } = await import("@formbricks/logger");
expect(logger.error).toHaveBeenCalledWith(
expect.any(Error),
"Token verification failed with new method"
);
expect(logger.error).toHaveBeenCalledWith(
expect.any(Error),
"Token verification failed with legacy method"
);
});
});
describe("verifyInviteToken", () => {
@@ -139,6 +473,53 @@ describe("JWT Functions", () => {
test("should throw error for invalid token", () => {
expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token");
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(verifyInviteToken, ["any-token"]);
});
test("should throw error if inviteId is missing", () => {
const tokenWithoutInviteId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
expect(() => verifyInviteToken(tokenWithoutInviteId)).toThrow("Invalid or expired invite token");
});
test("should throw error if email is missing", () => {
const tokenWithoutEmail = jwt.sign({ inviteId: "test-invite-id" }, TEST_NEXTAUTH_SECRET);
expect(() => verifyInviteToken(tokenWithoutEmail)).toThrow("Invalid or expired invite token");
});
test("should fall back to original values if decryption fails", () => {
mockSymmetricDecrypt.mockImplementation(() => {
throw new Error("Decryption failed");
});
const inviteId = "test-invite-id";
const legacyToken = jwt.sign(
{
inviteId,
email: mockUser.email,
},
TEST_NEXTAUTH_SECRET
);
const verified = verifyInviteToken(legacyToken);
expect(verified).toEqual({
inviteId,
email: mockUser.email,
});
});
test("should throw error for token with wrong signature", () => {
const invalidToken = jwt.sign(
{
inviteId: "test-invite-id",
email: mockUser.email,
},
DIFFERENT_SECRET
);
expect(() => verifyInviteToken(invalidToken)).toThrow("Invalid or expired invite token");
});
});
describe("verifyEmailChangeToken", () => {
@@ -150,22 +531,478 @@ describe("JWT Functions", () => {
expect(result).toEqual({ id: userId, email });
});
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
await testMissingSecretsError(verifyEmailChangeToken, ["any-token"], { isAsync: true });
});
test("should throw error if token is invalid or missing fields", async () => {
// Create a token with missing fields
const jwt = await import("jsonwebtoken");
const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string);
const token = jwt.sign({ foo: "bar" }, TEST_NEXTAUTH_SECRET);
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
"Token is invalid or missing required fields"
);
});
test("should throw error if id is missing", async () => {
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
"Token is invalid or missing required fields"
);
});
test("should throw error if email is missing", async () => {
const token = jwt.sign({ id: "test-id" }, TEST_NEXTAUTH_SECRET);
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
"Token is invalid or missing required fields"
);
});
test("should return original id/email if decryption fails", async () => {
// Create a token with non-encrypted id/email
const jwt = await import("jsonwebtoken");
mockSymmetricDecrypt.mockImplementation(() => {
throw new Error("Decryption failed");
});
const payload = { id: "plain-id", email: "plain@example.com" };
const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string);
const token = jwt.sign(payload, TEST_NEXTAUTH_SECRET);
const result = await verifyEmailChangeToken(token);
expect(result).toEqual(payload);
});
test("should throw error for token with wrong signature", async () => {
const invalidToken = jwt.sign(
{
id: "test-id",
email: "test@example.com",
},
DIFFERENT_SECRET
);
await expect(verifyEmailChangeToken(invalidToken)).rejects.toThrow();
});
});
// SECURITY SCENARIO TESTS
describe("Security Scenarios", () => {
describe("Algorithm Confusion Attack Prevention", () => {
test("should reject 'none' algorithm tokens in verifyToken", async () => {
// Create malicious token with "none" algorithm
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
id: "encrypted_malicious-id",
})
).toString("base64url") +
".";
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
});
test("should reject 'none' algorithm tokens in verifyTokenForLinkSurvey", () => {
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
email: "encrypted_attacker@evil.com",
surveyId: "test-survey-id",
})
).toString("base64url") +
".";
const result = verifyTokenForLinkSurvey(maliciousToken, "test-survey-id");
expect(result).toBeNull();
});
test("should reject 'none' algorithm tokens in verifyInviteToken", () => {
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
inviteId: "encrypted_malicious-invite",
email: "encrypted_attacker@evil.com",
})
).toString("base64url") +
".";
expect(() => verifyInviteToken(maliciousToken)).toThrow("Invalid or expired invite token");
});
test("should reject 'none' algorithm tokens in verifyEmailChangeToken", async () => {
const maliciousToken =
Buffer.from(
JSON.stringify({
alg: "none",
typ: "JWT",
})
).toString("base64url") +
"." +
Buffer.from(
JSON.stringify({
id: "encrypted_malicious-id",
email: "encrypted_attacker@evil.com",
})
).toString("base64url") +
".";
await expect(verifyEmailChangeToken(maliciousToken)).rejects.toThrow();
});
test("should reject RS256 algorithm tokens (HS256/RS256 confusion)", async () => {
// Create malicious token with RS256 algorithm header but HS256 signature
const maliciousHeader = Buffer.from(
JSON.stringify({
alg: "RS256",
typ: "JWT",
})
).toString("base64url");
const maliciousPayload = Buffer.from(
JSON.stringify({
id: "encrypted_malicious-id",
})
).toString("base64url");
// Create signature using HMAC (as if it were HS256)
const crypto = require("crypto");
const signature = crypto
.createHmac("sha256", TEST_NEXTAUTH_SECRET)
.update(`${maliciousHeader}.${maliciousPayload}`)
.digest("base64url");
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.${signature}`;
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
});
test("should only accept HS256 algorithm", async () => {
// Test that other valid algorithms are rejected
const otherAlgorithms = ["HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"];
for (const alg of otherAlgorithms) {
const maliciousHeader = Buffer.from(
JSON.stringify({
alg,
typ: "JWT",
})
).toString("base64url");
const maliciousPayload = Buffer.from(
JSON.stringify({
id: "encrypted_test-id",
})
).toString("base64url");
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.fake-signature`;
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
}
});
});
describe("Token Tampering", () => {
test("should reject tokens with modified payload", async () => {
const token = createToken(mockUser.id);
const [header, payload, signature] = token.split(".");
// Modify the payload
const decodedPayload = JSON.parse(Buffer.from(payload, "base64url").toString());
decodedPayload.id = "malicious-id";
const tamperedPayload = Buffer.from(JSON.stringify(decodedPayload)).toString("base64url");
const tamperedToken = `${header}.${tamperedPayload}.${signature}`;
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
});
test("should reject tokens with modified signature", async () => {
const token = createToken(mockUser.id);
const [header, payload] = token.split(".");
const tamperedToken = `${header}.${payload}.tamperedsignature`;
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
});
test("should reject malformed tokens", async () => {
const malformedTokens = [
"not.a.jwt",
"only.two.parts",
"too.many.parts.here.invalid",
"",
"invalid-base64",
];
for (const malformedToken of malformedTokens) {
await expect(verifyToken(malformedToken)).rejects.toThrow();
}
});
});
describe("Cross-Survey Token Reuse", () => {
test("should reject survey tokens used for different surveys", () => {
const surveyId1 = "survey-1";
const surveyId2 = "survey-2";
const token = createTokenForLinkSurvey(surveyId1, mockUser.email);
const result = verifyTokenForLinkSurvey(token, surveyId2);
expect(result).toBeNull();
});
});
describe("Expired Tokens", () => {
test("should reject expired tokens", async () => {
const expiredToken = jwt.sign(
{
id: "encrypted_test-id",
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
},
TEST_NEXTAUTH_SECRET
);
await expect(verifyToken(expiredToken)).rejects.toThrow("Invalid token");
});
test("should reject expired email change tokens", async () => {
const expiredToken = jwt.sign(
{
id: "encrypted_test-id",
email: "encrypted_test@example.com",
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
},
TEST_NEXTAUTH_SECRET
);
await expect(verifyEmailChangeToken(expiredToken)).rejects.toThrow();
});
});
describe("Encryption Key Attacks", () => {
test("should fail gracefully with wrong encryption key", async () => {
mockSymmetricDecrypt.mockImplementation(() => {
throw new Error("Authentication tag verification failed");
});
// Mock findUnique to only return user for correct decrypted ID, not ciphertext
(prisma.user.findUnique as any).mockImplementation(({ where }: { where: { id: string } }) => {
if (where.id === mockUser.id) {
return Promise.resolve(mockUser);
}
return Promise.resolve(null); // Return null for ciphertext IDs
});
const token = createToken(mockUser.id);
// Should fail because ciphertext passed as userId won't match any user in DB
await expect(verifyToken(token)).rejects.toThrow(/User not found/i);
});
test("should handle encryption key not set gracefully", async () => {
const constants = await import("@/lib/constants");
const originalKey = (constants as any).ENCRYPTION_KEY;
(constants as any).ENCRYPTION_KEY = undefined;
const token = jwt.sign(
{
email: "test@example.com",
surveyId: "test-survey-id",
},
TEST_NEXTAUTH_SECRET
);
const result = verifyTokenForLinkSurvey(token, "test-survey-id");
expect(result).toBe("test@example.com");
// Restore
(constants as any).ENCRYPTION_KEY = originalKey;
});
});
describe("SQL Injection Attempts", () => {
test("should safely handle malicious user IDs", async () => {
const maliciousIds = [
"'; DROP TABLE users; --",
"1' OR '1'='1",
"admin'/*",
"<script>alert('xss')</script>",
"../../etc/passwd",
];
for (const maliciousId of maliciousIds) {
mockSymmetricDecrypt.mockReturnValueOnce(maliciousId);
const token = jwt.sign({ id: "encrypted_malicious" }, TEST_NEXTAUTH_SECRET);
// The function should look up the user safely
await verifyToken(token);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: maliciousId },
});
}
});
});
describe("Token Reuse and Replay Attacks", () => {
test("should allow legitimate token reuse within validity period", async () => {
const token = createToken(mockUser.id);
// First use
const result1 = await verifyToken(token);
expect(result1.id).toBe(mockUser.id); // Returns decrypted user ID
// Second use (should still work)
const result2 = await verifyToken(token);
expect(result2.id).toBe(mockUser.id); // Returns decrypted user ID
});
});
describe("Legacy Token Compatibility", () => {
test("should handle legacy unencrypted tokens gracefully", async () => {
// Legacy token with plain text data
const legacyToken = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
const result = await verifyToken(legacyToken);
expect(result.id).toBe(mockUser.id); // Returns raw ID from payload
expect(result.email).toBe(mockUser.email);
});
test("should handle mixed encrypted/unencrypted fields", async () => {
mockSymmetricDecrypt
.mockImplementationOnce(() => mockUser.id) // id decrypts successfully
.mockImplementationOnce(() => {
throw new Error("Email not encrypted");
}); // email fails
const token = jwt.sign(
{
id: "encrypted_test-id",
email: "plain-email@example.com",
},
TEST_NEXTAUTH_SECRET
);
const result = await verifyEmailChangeToken(token);
expect(result.id).toBe(mockUser.id);
expect(result.email).toBe("plain-email@example.com");
});
test("should verify old format user tokens with email-based secrets", async () => {
// Simulate old token format with per-user secret
const oldFormatToken = jwt.sign(
{ id: `encrypted_${mockUser.id}` },
TEST_NEXTAUTH_SECRET + mockUser.email
);
const result = await verifyToken(oldFormatToken);
expect(result.id).toBe(mockUser.id); // Returns decrypted user ID
expect(result.email).toBe(mockUser.email);
});
test("should verify old format survey tokens with survey-based secrets", () => {
const surveyId = "legacy-survey-id";
// Simulate old survey token format
const oldFormatSurveyToken = jwt.sign(
{ email: `encrypted_${mockUser.email}` },
TEST_NEXTAUTH_SECRET + surveyId
);
const result = verifyTokenForLinkSurvey(oldFormatSurveyToken, surveyId);
expect(result).toBe(mockUser.email);
});
test("should gracefully handle database errors during legacy verification", async () => {
// Create token that will fail new method
const legacyToken = jwt.sign(
{ id: `encrypted_${mockUser.id}` },
TEST_NEXTAUTH_SECRET + mockUser.email
);
// Make database lookup fail
(prisma.user.findUnique as any).mockRejectedValueOnce(new Error("DB connection lost"));
await expect(verifyToken(legacyToken)).rejects.toThrow("DB connection lost");
});
});
describe("Edge Cases and Error Handling", () => {
test("should handle database connection errors gracefully", async () => {
(prisma.user.findUnique as any).mockRejectedValue(new Error("Database connection failed"));
const token = createToken(mockUser.id);
await expect(verifyToken(token)).rejects.toThrow("Database connection failed");
});
test("should handle crypto module errors", () => {
mockSymmetricEncrypt.mockImplementation(() => {
throw new Error("Crypto module error");
});
expect(() => createToken(mockUser.id)).toThrow("Crypto module error");
});
test("should validate email format in tokens", () => {
const invalidEmails = ["", "not-an-email", "missing@", "@missing-local.com", "spaces in@email.com"];
invalidEmails.forEach((invalidEmail) => {
expect(() => createEmailToken(invalidEmail)).not.toThrow();
// Note: JWT functions don't validate email format, they just encrypt/decrypt
// Email validation should happen at a higher level
});
});
test("should handle extremely long inputs", () => {
const longString = "a".repeat(10000);
expect(() => createToken(longString)).not.toThrow();
expect(() => createEmailToken(longString)).not.toThrow();
});
test("should handle special characters in user data", () => {
const specialChars = "!@#$%^&*()_+-=[]{}|;:'\",.<>?/~`";
expect(() => createToken(specialChars)).not.toThrow();
expect(() => createEmailToken(specialChars)).not.toThrow();
});
});
describe("Performance and Resource Exhaustion", () => {
test("should handle rapid token creation without memory leaks", () => {
const tokens: string[] = [];
for (let i = 0; i < 1000; i++) {
tokens.push(createToken(`user-${i}`));
}
expect(tokens.length).toBe(1000);
expect(tokens.every((token) => typeof token === "string")).toBe(true);
});
test("should handle rapid token verification", async () => {
const token = createToken(mockUser.id);
const verifications: Promise<any>[] = [];
for (let i = 0; i < 100; i++) {
verifications.push(verifyToken(token));
}
const results = await Promise.all(verifications);
expect(results.length).toBe(100);
expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
});
});
});
});

View File

@@ -1,43 +1,64 @@
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { env } from "@/lib/env";
import jwt, { JwtPayload } from "jsonwebtoken";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
export const createToken = (userId: string, userEmail: string, options = {}): string => {
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
};
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
// Helper function to decrypt with fallback to plain text
const decryptWithFallback = (encryptedText: string, key: string): string => {
try {
return symmetricDecrypt(encryptedText, key);
} catch {
return encryptedText; // Return as-is if decryption fails (legacy format)
}
};
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
if (!env.NEXTAUTH_SECRET) {
export const createToken = (userId: string, options = {}): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string };
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
};
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedEmail = symmetricEncrypt(userEmail, ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail, surveyId }, NEXTAUTH_SECRET);
};
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as {
id: string;
email: string;
};
if (!payload?.id || !payload?.email) {
throw new Error("Token is invalid or missing required fields");
}
let decryptedId: string;
let decryptedEmail: string;
try {
decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY);
} catch {
decryptedId = payload.id;
}
try {
decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
} catch {
decryptedEmail = payload.email;
}
// Decrypt both fields with fallback
const decryptedId = decryptWithFallback(payload.id, ENCRYPTION_KEY);
const decryptedEmail = decryptWithFallback(payload.email, ENCRYPTION_KEY);
return {
id: decryptedId,
@@ -46,127 +67,230 @@ export const verifyEmailChangeToken = async (token: string): Promise<{ id: strin
};
export const createEmailChangeToken = (userId: string, email: string): string => {
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
const payload = {
id: encryptedUserId,
email: encryptedEmail,
};
return jwt.sign(payload, env.NEXTAUTH_SECRET as string, {
return jwt.sign(payload, NEXTAUTH_SECRET, {
expiresIn: "1d",
});
};
export const createEmailToken = (email: string): string => {
if (!env.NEXTAUTH_SECRET) {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET);
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
return jwt.sign({ email: encryptedEmail }, NEXTAUTH_SECRET);
};
export const getEmailFromEmailToken = (token: string): string => {
if (!env.NEXTAUTH_SECRET) {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as JwtPayload;
try {
// Try to decrypt first (for newer tokens)
const decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
return decryptedEmail;
} catch {
// If decryption fails, return the original email (for older tokens)
return payload.email;
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
email: string;
};
return decryptWithFallback(payload.email, ENCRYPTION_KEY);
};
export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
if (!env.NEXTAUTH_SECRET) {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
const encryptedInviteId = symmetricEncrypt(inviteId, env.ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, env.NEXTAUTH_SECRET, options);
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const encryptedInviteId = symmetricEncrypt(inviteId, ENCRYPTION_KEY);
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, NEXTAUTH_SECRET, options);
};
export const verifyTokenForLinkSurvey = (token: string, surveyId: string): string | null => {
if (!NEXTAUTH_SECRET) {
return null;
}
try {
const { email } = jwt.verify(token, env.NEXTAUTH_SECRET + surveyId) as JwtPayload;
let payload: JwtPayload & { email: string; surveyId?: string };
// Try primary method first (consistent secret)
try {
// Try to decrypt first (for newer tokens)
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
email: string;
surveyId: string;
};
} catch (primaryError) {
logger.error(primaryError, "Token verification failed with primary method");
// Fallback to legacy method (surveyId-based secret)
try {
payload = jwt.verify(token, NEXTAUTH_SECRET + surveyId, { algorithms: ["HS256"] }) as JwtPayload & {
email: string;
};
} catch (legacyError) {
logger.error(legacyError, "Token verification failed with legacy method");
throw new Error("Invalid token");
}
const decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
return decryptedEmail;
} catch {
// If decryption fails, return the original email (for older tokens)
return email;
}
} catch (err) {
// Verify the surveyId matches if present in payload (new format)
if (payload.surveyId && payload.surveyId !== surveyId) {
return null;
}
const { email } = payload;
if (!email) {
return null;
}
// Decrypt email with fallback to plain text
if (!ENCRYPTION_KEY) {
return email; // Return as-is if encryption key not set
}
return decryptWithFallback(email, ENCRYPTION_KEY);
} catch (error) {
logger.error(error, "Survey link token verification failed");
return null;
}
};
export const verifyToken = async (token: string): Promise<JwtPayload> => {
// First decode to get the ID
const decoded = jwt.decode(token);
const payload: JwtPayload = decoded as JwtPayload;
// Helper function to get user email for legacy verification
const getUserEmailForLegacyVerification = async (
token: string,
userId?: string
): Promise<{ userId: string; userEmail: string }> => {
if (!userId) {
const decoded = jwt.decode(token);
if (!payload) {
throw new Error("Token is invalid");
// Validate decoded token structure before using it
if (
!decoded ||
typeof decoded !== "object" ||
!decoded.id ||
typeof decoded.id !== "string" ||
decoded.id.trim() === ""
) {
logger.error("Invalid token: missing or invalid user ID");
throw new Error("Invalid token");
}
userId = decoded.id;
}
const { id } = payload;
if (!id) {
throw new Error("Token missing required field: id");
const decryptedId = decryptWithFallback(userId, ENCRYPTION_KEY);
// Validate decrypted ID before database query
if (!decryptedId || typeof decryptedId !== "string" || decryptedId.trim() === "") {
logger.error("Invalid token: missing or invalid user ID");
throw new Error("Invalid token");
}
// Try to decrypt the ID (for newer tokens), if it fails use the ID as-is (for older tokens)
let decryptedId: string;
try {
decryptedId = symmetricDecrypt(id, env.ENCRYPTION_KEY);
} catch {
decryptedId = id;
}
// If no email provided, look up the user
const foundUser = await prisma.user.findUnique({
where: { id: decryptedId },
});
if (!foundUser) {
throw new Error("User not found");
const errorMessage = "User not found";
logger.error(errorMessage);
throw new Error(errorMessage);
}
const userEmail = foundUser.email;
return { userId: decryptedId, userEmail: foundUser.email };
};
return { id: decryptedId, email: userEmail };
export const verifyToken = async (token: string): Promise<JwtPayload> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
let payload: JwtPayload & { id: string };
let userData: { userId: string; userEmail: string } | null = null;
// Try new method first, with smart fallback to legacy
try {
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
id: string;
};
} catch (newMethodError) {
logger.error(newMethodError, "Token verification failed with new method");
// Get user email for legacy verification
userData = await getUserEmailForLegacyVerification(token);
// Try legacy verification with email-based secret
try {
payload = jwt.verify(token, NEXTAUTH_SECRET + userData.userEmail, {
algorithms: ["HS256"],
}) as JwtPayload & {
id: string;
};
} catch (legacyMethodError) {
logger.error(legacyMethodError, "Token verification failed with legacy method");
throw new Error("Invalid token");
}
}
if (!payload?.id) {
throw new Error("Invalid token");
}
// Get user email if we don't have it yet
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
return { id: userData.userId, email: userData.userEmail };
};
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
try {
const decoded = jwt.decode(token);
const payload: JwtPayload = decoded as JwtPayload;
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
inviteId: string;
email: string;
};
const { inviteId, email } = payload;
const { inviteId: encryptedInviteId, email: encryptedEmail } = payload;
let decryptedInviteId: string;
let decryptedEmail: string;
try {
// Try to decrypt first (for newer tokens)
decryptedInviteId = symmetricDecrypt(inviteId, env.ENCRYPTION_KEY);
decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
} catch {
// If decryption fails, use original values (for older tokens)
decryptedInviteId = inviteId;
decryptedEmail = email;
if (!encryptedInviteId || !encryptedEmail) {
throw new Error("Invalid token");
}
// Decrypt both fields with fallback to original values
const decryptedInviteId = decryptWithFallback(encryptedInviteId, ENCRYPTION_KEY);
const decryptedEmail = decryptWithFallback(encryptedEmail, ENCRYPTION_KEY);
return {
inviteId: decryptedInviteId,
email: decryptedEmail,

View File

@@ -1,12 +1,12 @@
import { randomBytes } from "crypto";
import { Provider } from "next-auth/providers/index";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { createToken } from "@/lib/jwt";
// Import mocked rate limiting functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { randomBytes } from "crypto";
import { Provider } from "next-auth/providers/index";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
@@ -31,7 +31,7 @@ vi.mock("@/lib/constants", () => ({
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
@@ -261,7 +261,7 @@ describe("authOptions", () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
const credentials = { token: createToken(mockUser.id, mockUser.email) };
const credentials = { token: createToken(mockUser.id) };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
"Email already verified"
@@ -280,7 +280,7 @@ describe("authOptions", () => {
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
const credentials = { token: createToken(mockUserId) };
const result = await tokenProvider.options.authorize(credentials, {});
expect(result.email).toBe(mockUser.email);
@@ -303,7 +303,7 @@ describe("authOptions", () => {
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
const credentials = { token: createToken(mockUserId) };
await tokenProvider.options.authorize(credentials, {});
@@ -315,7 +315,7 @@ describe("authOptions", () => {
new Error("Maximum number of requests reached. Please try again later.")
);
const credentials = { token: createToken(mockUserId, mockUser.email) };
const credentials = { token: createToken(mockUserId) };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
@@ -339,7 +339,7 @@ describe("authOptions", () => {
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
const credentials = { token: createToken(mockUserId) };
await tokenProvider.options.authorize(credentials, {});

View File

@@ -1,3 +1,12 @@
import { render } from "@react-email/render";
import { createTransport } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import { logger } from "@formbricks/logger";
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
import { InvalidInputError } from "@formbricks/types/errors";
import type { TResponse } from "@formbricks/types/responses";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
import {
DEBUG,
MAIL_FROM,
@@ -17,15 +26,6 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification";
import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
import { getTranslate } from "@/tolgee/server";
import { render } from "@react-email/render";
import { createTransport } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import { logger } from "@formbricks/logger";
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
import { InvalidInputError } from "@formbricks/types/errors";
import type { TResponse } from "@formbricks/types/responses";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
import { ForgotPasswordEmail } from "./emails/auth/forgot-password-email";
import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
import { VerificationEmail } from "./emails/auth/verification-email";
@@ -111,7 +111,7 @@ export const sendVerificationEmail = async ({
}): Promise<boolean> => {
try {
const t = await getTranslate();
const token = createToken(id, email, {
const token = createToken(id, {
expiresIn: "1d",
});
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
@@ -136,7 +136,7 @@ export const sendForgotPasswordEmail = async (user: {
locale: TUserLocale;
}): Promise<boolean> => {
const t = await getTranslate();
const token = createToken(user.id, user.email, {
const token = createToken(user.id, {
expiresIn: "1d",
});
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;

View File

@@ -241,4 +241,40 @@ describe("ConditionalLogic", () => {
expect(screen.getAllByTestId("logic-editor").length).toBe(2);
});
test("should clear logicFallback when logic array is empty and logicFallback exists (useEffect)", () => {
const mockUpdateQuestion = vi.fn();
const mockQuestion: TSurveyQuestion = {
id: "testQuestionId",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Test Question" },
required: false,
inputType: "text",
charLimit: { enabled: false },
logic: [], // Empty logic array
logicFallback: "someTarget", // Has logicFallback but no logic
};
const mockSurvey = {
id: "testSurveyId",
name: "Test Survey",
type: "link",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "testEnvId",
status: "inProgress",
questions: [mockQuestion],
endings: [],
} as unknown as TSurvey;
render(
<ConditionalLogic
localSurvey={mockSurvey}
question={mockQuestion}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
/>
);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { logicFallback: undefined });
});
});

View File

@@ -27,7 +27,7 @@ import {
SplitIcon,
TrashIcon,
} from "lucide-react";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
interface ConditionalLogicProps {
@@ -117,6 +117,12 @@ export function ConditionalLogic({
};
const [parent] = useAutoAnimate();
useEffect(() => {
if (question.logic?.length === 0 && question.logicFallback) {
updateQuestion(questionIdx, { logicFallback: undefined });
}
}, [question.logic, questionIdx, question.logicFallback, updateQuestion]);
return (
<div className="mt-4" ref={parent}>
<Label className="flex gap-2">

View File

@@ -59,8 +59,8 @@ export const BadgeContent: React.FC<BadgeContentProps> = ({
};
const content = (
<button
type="button"
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/prefer-tag-over-role, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
role={isCopyEnabled ? "button" : undefined}
className={getButtonClasses()}
onClick={handleCopy}
@@ -69,7 +69,7 @@ export const BadgeContent: React.FC<BadgeContentProps> = ({
onMouseLeave={isCopyEnabled ? () => setIsHovered(false) : undefined}>
<span>{id}</span>
{renderIcon()}
</button>
</div>
);
const getTooltipContent = () => {

View File

@@ -96,7 +96,7 @@ describe("IdBadge", () => {
test("removes interactive elements when copy is disabled", () => {
const { container } = render(<IdBadge id="1734" copyDisabled={true} />);
const badge = container.querySelector("button");
const badge = container.querySelector("div");
// Should not have cursor-pointer class
expect(badge).not.toHaveClass("cursor-pointer");

View File

@@ -424,12 +424,20 @@ EOT
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
fi
# Step 3: Build service snippets and inject them BEFORE the volumes section (robust, no sed -i multiline)
# Step 3: Build service snippets and inject them BEFORE the volumes section (non-destructive: skip if service exists)
services_snippet_file="services_snippet.yml"
: > "$services_snippet_file"
insert_traefik="y"
if grep -q "^ traefik:" docker-compose.yml; then insert_traefik="n"; fi
if [[ $minio_storage == "y" ]]; then
cat > "$services_snippet_file" << EOF
insert_minio="y"; insert_minio_init="y"
if grep -q "^ minio:" docker-compose.yml; then insert_minio="n"; fi
if grep -q "^ minio-init:" docker-compose.yml; then insert_minio_init="n"; fi
if [[ $insert_minio == "y" ]]; then
cat >> "$services_snippet_file" << EOF
minio:
restart: always
@@ -458,6 +466,11 @@ EOT
- "traefik.http.middlewares.minio-cors.headers.addvaryheader=true"
- "traefik.http.middlewares.minio-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.minio-ratelimit.ratelimit.burst=200"
EOF
fi
if [[ $insert_minio_init == "y" ]]; then
cat >> "$services_snippet_file" << EOF
minio-init:
image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868
depends_on:
@@ -471,7 +484,11 @@ EOT
entrypoint: ["/bin/sh", "/tmp/minio-init.sh"]
volumes:
- ./minio-init.sh:/tmp/minio-init.sh:ro
EOF
fi
if [[ $insert_traefik == "y" ]]; then
cat >> "$services_snippet_file" << EOF
traefik:
image: "traefik:v2.7"
restart: always
@@ -488,6 +505,7 @@ EOT
- ./acme.json:/acme.json
- /var/run/docker.sock:/var/run/docker.sock:ro
EOF
fi
# Downgrade MinIO router to plain HTTP when HTTPS is not configured
if [[ $https_setup != "y" ]]; then
@@ -497,7 +515,8 @@ EOF
sed -i "s|accesscontrolalloworiginlist=https://$domain_name|accesscontrolalloworiginlist=http://$domain_name|" "$services_snippet_file"
fi
else
cat > "$services_snippet_file" << EOF
if [[ $insert_traefik == "y" ]]; then
cat > "$services_snippet_file" << EOF
traefik:
image: "traefik:v2.7"
@@ -514,6 +533,9 @@ EOF
- ./acme.json:/acme.json
- /var/run/docker.sock:/var/run/docker.sock:ro
EOF
else
: > "$services_snippet_file"
fi
fi
awk '
@@ -529,24 +551,51 @@ EOF
rm -f "$services_snippet_file"
# Deterministically rewrite the volumes section to include required volumes
awk -v add_minio="$minio_storage" '
BEGIN { in_vol=0 }
/^volumes:/ {
print "volumes:";
print " postgres:";
print " driver: local";
print " uploads:";
print " driver: local";
if (add_minio == "y") {
print " minio-data:";
print " driver: local";
}
in_vol=1; skip=1; next
}
# Skip original volumes block lines until EOF (we already printed ours)
{ if (!skip) print }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
# Ensure required volumes exist without removing user-defined volumes
if grep -q '^volumes:' docker-compose.yml; then
# Ensure postgres
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="postgres:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
awk '
/^volumes:/ { print; invol=1; next }
invol && /^[^[:space:]]/ { if(!added){ print " postgres:"; print " driver: local"; added=1 } ; invol=0 }
{ print }
END { if (invol && !added) { print " postgres:"; print " driver: local" } }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
fi
# Ensure redis
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="redis:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
awk '
/^volumes:/ { print; invol=1; next }
invol && /^[^[:space:]]/ { if(!added){ print " redis:"; print " driver: local"; added=1 } ; invol=0 }
{ print }
END { if (invol && !added) { print " redis:"; print " driver: local" } }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
fi
# Ensure minio-data if needed
if [[ $minio_storage == "y" ]]; then
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="minio-data:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
awk '
/^volumes:/ { print; invol=1; next }
invol && /^[^[:space:]]/ { if(!added){ print " minio-data:"; print " driver: local"; added=1 } ; invol=0 }
{ print }
END { if (invol && !added) { print " minio-data:"; print " driver: local" } }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
fi
fi
else
{
echo ""
echo "volumes:"
echo " postgres:"
echo " driver: local"
echo " redis:"
echo " driver: local"
if [[ $minio_storage == "y" ]]; then
echo " minio-data:"
echo " driver: local"
fi
} >> docker-compose.yml
fi
# Create minio-init script outside heredoc to avoid variable expansion issues
if [[ $minio_storage == "y" ]]; then
@@ -618,105 +667,6 @@ MINIO_SCRIPT_EOF
docker compose up -d
if [[ $minio_storage == "y" ]]; then
echo " Waiting for MinIO to be ready..."
attempts=0
max_attempts=30
until docker run --rm --network $(basename "$PWD")_default --entrypoint /bin/sh \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc \
"mc alias set minio http://minio:9000 '$minio_root_user' '$minio_root_password' >/dev/null 2>&1 && mc admin info minio >/dev/null 2>&1"; do
attempts=$((attempts+1))
if [ $attempts -ge $max_attempts ]; then
echo "❌ MinIO did not become ready in time. Proceeding, but subsequent steps may fail."
break
fi
echo "...attempt $attempts/$max_attempts"
sleep 5
done
echo " Ensuring bucket exists..."
docker run --rm --network $(basename "$PWD")_default \
-e MINIO_ROOT_USER="$minio_root_user" \
-e MINIO_ROOT_PASSWORD="$minio_root_password" \
-e MINIO_BUCKET_NAME="$minio_bucket_name" \
--entrypoint /bin/sh \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc '
mc alias set minio http://minio:9000 "$minio_root_user" "$minio_root_password" >/dev/null 2>&1;
mc mb minio/"$minio_bucket_name" --ignore-existing
'
echo " Ensuring service user and policy exist (idempotent)..."
docker run --rm --network $(basename "$PWD")_default \
-e MINIO_ROOT_USER="$minio_root_user" \
-e MINIO_ROOT_PASSWORD="$minio_root_password" \
-e MINIO_SERVICE_USER="$minio_service_user" \
-e MINIO_SERVICE_PASSWORD="$minio_service_password" \
-e MINIO_BUCKET_NAME="$minio_bucket_name" \
--entrypoint /bin/sh \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc '
mc alias set minio http://minio:9000 "$minio_root_user" "$minio_root_password" >/dev/null 2>&1;
if ! mc admin policy info minio formbricks-policy >/dev/null 2>&1; then
cat > /tmp/formbricks-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"], "Resource": ["arn:aws:s3:::$minio_bucket_name/*"] },
{ "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::$minio_bucket_name"] }
]
}
EOF
mc admin policy create minio formbricks-policy /tmp/formbricks-policy.json >/dev/null 2>&1 || true
fi;
if ! mc admin user info minio "$minio_service_user" >/dev/null 2>&1; then
mc admin user add minio "$minio_service_user" "$minio_service_password" >/dev/null 2>&1 || true
fi;
mc admin policy attach minio formbricks-policy --user "$minio_service_user" >/dev/null 2>&1 || true
'
fi
if [[ $minio_storage == "y" ]]; then
echo "⏳ Finalizing MinIO setup..."
attempts=0; max_attempts=60
while cid=$(docker compose ps -q minio-init 2>/dev/null); do
status=$(docker inspect -f '{{.State.Status}}' "$cid" 2>/dev/null || echo "")
if [ "$status" = "exited" ] || [ -z "$status" ]; then
break
fi
attempts=$((attempts+1))
if [ $attempts -ge $max_attempts ]; then
echo "⚠️ minio-init still running after wait; proceeding with cleanup anyway."
break
fi
sleep 2
done
echo "🧹 Cleaning up minio-init service and references..."
awk '
BEGIN{skip=0}
/^services:[[:space:]]*$/ { print; next }
/^ minio-init:/ { skip=1; next }
/^ [A-Za-z0-9_-]+:/ { if (skip) skip=0 }
{ if (!skip) print }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
# Remove list-style "- minio-init" lines under depends_on (if any)
sed -E -i '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
# Remove the minio-init mapping and its condition line
sed -i '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
# Remove any stopped minio-init container and restart without orphans
docker compose rm -f -s minio-init >/dev/null 2>&1 || true
docker compose up -d --remove-orphans
# Clean up the temporary minio-init script
rm -f minio-init.sh
echo "✅ MinIO one-time init cleaned up."
fi
echo "🔗 To edit more variables and deeper config, go to the formbricks/docker-compose.yml, edit the file, and restart the container!"
echo "🚨 Make sure you have set up the DNS records as well as inbound rules for the domain name and IP address of this instance."
@@ -780,6 +730,40 @@ get_logs() {
sudo docker compose logs
}
cleanup_minio_init() {
echo "🧹 Cleaning up MinIO init service and references..."
cd formbricks
# Remove minio-init service block from docker-compose.yml
awk '
BEGIN{skip=0}
/^services:[[:space:]]*$/ { print; next }
/^ minio-init:/ { skip=1; next }
/^ [A-Za-z0-9_-]+:/ { if (skip) skip=0 }
{ if (!skip) print }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
# Remove list-style "- minio-init" lines under depends_on (if any)
if sed --version >/dev/null 2>&1; then
sed -E -i '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
else
sed -E -i '' '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
fi
# Remove the minio-init mapping and its condition line (mapping style depends_on)
if sed --version >/dev/null 2>&1; then
sed -i '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
else
sed -i '' '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
fi
# Remove any stopped minio-init container and restart without orphans
docker compose rm -f -s minio-init >/dev/null 2>&1 || true
docker compose up -d --remove-orphans
echo "✅ MinIO init cleanup complete."
}
case "$1" in
install)
install_formbricks
@@ -796,6 +780,9 @@ restart)
logs)
get_logs
;;
cleanup-minio-init)
cleanup_minio_init
;;
uninstall)
uninstall_formbricks
;;

View File

@@ -45,7 +45,7 @@ external_s3_guard() {
if ! has_service minio; then
print_warning "Detected existing S3 credentials in docker-compose.yml and no bundled MinIO service."
print_error "This migration is intended only for setups using local uploads."
print_info "No changes were made. If you already use external S3 (incl. AWS), you don't need this migration."
print_info "No files were migrated. If you already use external S3 (incl. AWS), you don't need this migration."
exit 0
fi
fi
@@ -287,7 +287,8 @@ has_service() {
}
add_or_replace_env_var() {
local key="$1"; local value="$2"
local key="$1"; local value="$2"; local section="${3:-STORAGE}"
if grep -q "^[[:space:]]*$key:" docker-compose.yml; then
# Replace existing uncommented key
if sed --version >/dev/null 2>&1; then sed -i "s|^\([[:space:]]*$key:\).*|\1 \"$value\"|" docker-compose.yml; else sed -i '' "s|^\([[:space:]]*$key:\).*|\1 \"$value\"|" docker-compose.yml; fi
@@ -295,29 +296,77 @@ add_or_replace_env_var() {
# Uncomment placeholder and set
if sed --version >/dev/null 2>&1; then sed -i "s|^[[:space:]]*#[[:space:]]*$key:.*| $key: \"$value\"|" docker-compose.yml; else sed -i '' "s|^[[:space:]]*#[[:space:]]*$key:.*| $key: \"$value\"|" docker-compose.yml; fi
else
# Append into STORAGE section before OAUTH header if present
awk -v insert_key="$key" -v insert_val="$value" '
BEGIN{printed=0}
/################################################### OPTIONAL \(STORAGE\) ###################################################/ {print; in_storage=1; next}
in_storage && /############################################# OPTIONAL \(OAUTH CONFIGURATION\) #############################################/ && !printed { print " " insert_key ": \"" insert_val "\""; printed=1; print; in_storage=0; next }
{ print }
END { if(in_storage && !printed) print " " insert_key ": \"" insert_val "\"" }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
# Add to specified section with fallback
local section_found=false
if [[ "$section" == "REQUIRED" ]] && grep -q -E "^[[:space:]]*#+[[:space:]]*REQUIRED[[:space:]]*#+[[:space:]]*$" docker-compose.yml; then
# Add to REQUIRED section
awk -v insert_key="$key" -v insert_val="$value" '
/^[[:space:]]*#+[[:space:]]*REQUIRED[[:space:]]*#+[[:space:]]*$/ {print; in_required=1; next}
in_required && /^[[:space:]]*#+.*OPTIONAL/ && !printed {
print ""; print " # " insert_key " (required for Formbricks 4.0)";
print " " insert_key ": \"" insert_val "\""; printed=1
}
{ print }
END { if(in_required && !printed) { print ""; print " # " insert_key " (required for Formbricks 4.0)"; print " " insert_key ": \"" insert_val "\"" } }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
section_found=true
elif [[ "$section" == "STORAGE" ]] && grep -q -E "^[[:space:]]*#+[[:space:]]*OPTIONAL[[:space:]]*\\([[:space:]]*STORAGE[[:space:]]*\\)[[:space:]]*#+[[:space:]]*$" docker-compose.yml; then
# Add to STORAGE section (original behavior)
awk -v insert_key="$key" -v insert_val="$value" '
BEGIN{printed=0}
/^[[:space:]]*#+[[:space:]]*OPTIONAL[[:space:]]*\([[:space:]]*STORAGE[[:space:]]*\)[[:space:]]*#+[[:space:]]*$/ {print; in_storage=1; next}
in_storage && /^[[:space:]]*#+.*OPTIONAL.*OAUTH/ && !printed {
print " " insert_key ": \"" insert_val "\""; printed=1; print; in_storage=0; next
}
{ print }
END { if(in_storage && !printed) print " " insert_key ": \"" insert_val "\"" }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
section_found=true
fi
# Fallback: add at the end of environment block if section not found
if [[ "$section_found" == false ]]; then
awk -v insert_key="$key" -v insert_val="$value" '
/^ environment:/ {print; in_env=1; next}
in_env && /^[^[:space:]]/ {
if (!printed) { print " " insert_key ": \"" insert_val "\""; printed=1 }
in_env=0
}
{ print }
END { if(in_env && !printed) print " " insert_key ": \"" insert_val "\"" }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
fi
fi
}
# Function to check if we're in the correct directory
check_formbricks_directory() {
if [[ ! -f "docker-compose.yml" ]]; then
print_error "docker-compose.yml not found in current directory!"
print_info "Please run this script from your Formbricks installation directory (usually ./formbricks/)"
exit 1
# Case 1: docker-compose.yml in current directory
if [[ -f "docker-compose.yml" ]]; then
if grep -q "formbricks" docker-compose.yml; then
return 0
else
print_error "This doesn't appear to be a Formbricks docker-compose.yml file!"
exit 1
fi
fi
if ! grep -q "formbricks" docker-compose.yml; then
print_error "This doesn't appear to be a Formbricks docker-compose.yml file!"
exit 1
# Case 2: one-click setup parent directory containing ./formbricks/docker-compose.yml
if [[ -f "formbricks/docker-compose.yml" ]]; then
cd formbricks
print_status "Detected one-click setup layout. Switched to ./formbricks directory."
if ! grep -q "formbricks" docker-compose.yml; then
print_error "This doesn't appear to be a Formbricks docker-compose.yml file!"
exit 1
fi
return 0
fi
# Neither current directory nor ./formbricks contains a compose file
print_error "docker-compose.yml not found in current directory or in ./formbricks/"
print_info "Run this script from the parent directory created by the one-click setup (containing ./formbricks/), or from the directory containing docker-compose.yml."
exit 1
}
# Function to backup existing docker-compose.yml
@@ -635,35 +684,202 @@ ${tls_block}
print_status "Added MinIO service to docker-compose.yml"
}
# Function to add minio-init dependency to formbricks service
add_minio_dependency() {
# Only add if not already present
if ! awk '/formbricks:/,/depends_on:/{ if($0 ~ /minio-init/) found=1 } END{ exit(found) }' docker-compose.yml; then
if sed --version >/dev/null 2>&1; then sed -i '/formbricks:/,/depends_on:/{/- postgres/a\
- minio-init
}' docker-compose.yml; else sed -i '' '/formbricks:/,/depends_on:/{/- postgres/a\
- minio-init
}' docker-compose.yml; fi
print_status "Added minio-init dependency to formbricks service"
# Generic function to add service dependency
add_service_dependency() {
local service="$1" # Target service (e.g., "formbricks", "traefik")
local dependency="$2" # Dependency to add (e.g., "redis", "minio-init", "minio")
local optional="${3:-false}" # Optional parameter - if true, don't exit if service not found
# Check if service exists
if ! grep -q "^ $service:" docker-compose.yml; then
if [[ "$optional" == "true" ]]; then
print_info "$service service not found - skipping dependency addition."
return 0
else
print_error "$service service not found in docker-compose.yml!"
print_info "Please ensure the $service service is properly configured before running this migration."
exit 1
fi
fi
# Check if dependency is already present in the service depends_on section
if awk -v srv="$service" -v dep="$dependency" 'BEGIN{found=0} /^ / && $0 ~ "^ " srv ":" {in_svc=1} in_svc && /^ [a-zA-Z]/ && $0 !~ "^ " srv ":" {in_svc=0} in_svc && $0 ~ "^[[:space:]]*-[[:space:]]*" dep "[[:space:]]*$" {found=1} END{exit(!found)}' docker-compose.yml; then
# Dependency already present, skip addition
print_info "$dependency dependency already present in $service service."
else
print_info "minio-init dependency already present."
# Write awk script to temporary file to avoid shell escaping issues
local awk_script_tmp
awk_script_tmp=$(mktemp)
cat > "$awk_script_tmp" << 'AWK_EOF'
# Store all lines to be able to look ahead
{
lines[NR] = $0
}
END {
# First pass: check if target service has depends_on
for (i = 1; i <= NR; i++) {
if (lines[i] ~ "^ " srv ":") {
in_target = 1
start_line = i
} else if (in_target && lines[i] ~ /^ [a-zA-Z]/ && lines[i] !~ "^ " srv ":") {
in_target = 0
end_line = i - 1
break
}
if (in_target && lines[i] ~ /^[[:space:]]*depends_on:[[:space:]]*$/) {
has_depends_on = 1
}
}
if (in_target && !end_line) end_line = NR
# Second pass: output with modifications
in_svc = 0; in_depends = 0; added = 0
for (i = 1; i <= NR; i++) {
line = lines[i]
if (line ~ "^ " srv ":") {
in_svc = 1
print line
continue
}
if (in_svc && line ~ /^ [a-zA-Z]/ && line !~ "^ " srv ":") {
# Exiting service
if (!has_depends_on && !added) {
print " depends_on:"
print " - " new_dep
added = 1
}
in_svc = 0; in_depends = 0
print line
continue
}
if (in_svc && line ~ /^[[:space:]]*depends_on:[[:space:]]*$/) {
in_depends = 1
print line
continue
}
if (in_svc && in_depends && line ~ /^[[:space:]]*-[[:space:]]*/) {
print line
continue
}
if (in_svc && in_depends && line !~ /^[[:space:]]*-[[:space:]]*/) {
# End of depends_on list
if (!added) {
print " - " new_dep
added = 1
}
in_depends = 0
print line
continue
}
if (in_svc && line ~ /^[[:space:]]+[a-zA-Z_][^:]*:[[:space:]]*/) {
# First property in service without depends_on
if (!has_depends_on && !added) {
print " depends_on:"
print " - " new_dep
added = 1
has_depends_on = 1
}
print line
continue
}
print line
}
# Handle case where service ends at EOF
if (in_depends && !added) {
print " - " new_dep
} else if (in_svc && !has_depends_on && !added) {
print " depends_on:"
print " - " new_dep
}
}
AWK_EOF
# Use the awk script with variables
awk -v srv="$service" -v new_dep="$dependency" -f "$awk_script_tmp" docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
rm -f "$awk_script_tmp"
print_status "Added $dependency dependency to $service service"
fi
}
# Function to update Traefik configuration to include MinIO dependency
update_traefik_config() {
# Check if traefik service exists and add minio dependency
if grep -q "traefik:" docker-compose.yml; then
if ! awk '/traefik:/,/depends_on:/{ if($0 ~ /- minio$/) found=1 } END{ exit(found) }' docker-compose.yml; then
if sed --version >/dev/null 2>&1; then sed -i '/traefik:/,/depends_on:/{/- formbricks/a\
- minio
}' docker-compose.yml; else sed -i '' '/traefik:/,/depends_on:/{/- formbricks/a\
- minio
}' docker-compose.yml; fi
print_status "Updated Traefik configuration to include MinIO dependency"
else
print_info "Traefik already depends_on minio."
fi
# Function to add Redis environment variable
add_redis_environment_variables() {
# Use the enhanced helper function with REQUIRED section
add_or_replace_env_var "REDIS_URL" "redis://redis:6379" "REQUIRED"
print_status "Redis environment variables ensured in docker-compose.yml"
}
# Function to add Redis service to docker-compose.yml
add_redis_service() {
# Skip injecting if service already exists
if has_service redis; then
print_info "Redis service already present. Skipping service injection."
return 0
fi
# Create Redis service configuration
local redis_service_config="
redis:
restart: always
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
command: valkey-server --appendonly yes
volumes:
- redis:/data
"
# Write Redis service to temporary file
echo "$redis_service_config" > redis_service.tmp
# Add Redis service before the volumes section
awk '
{
print
if ($0 ~ /^services:$/ && !inserted) {
while ((getline line < "redis_service.tmp") > 0) print line
close("redis_service.tmp")
inserted = 1
}
}
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
# Clean up temporary file
rm -f redis_service.tmp
print_status "Added Redis service to docker-compose.yml"
}
# Function to add redis volume
add_redis_volume() {
# Ensure redis volume exists once
if grep -q '^volumes:' docker-compose.yml; then
# volumes block exists; check for redis inside it
if awk '/^volumes:/{invol=1; next} invol && NF==0{invol=0} invol{ if($1=="redis:") found=1 } END{ exit(!found) }' docker-compose.yml; then
print_info "Redis volume already present."
else
awk '
/^volumes:/ { print; invol=1; next }
invol && /^[^[:space:]]/ { if(!added){ print " redis:"; print " driver: local"; added=1 } ; invol=0 }
{ print }
END { if (invol && !added) { print " redis:"; print " driver: local" } }
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
print_status "Redis volume ensured"
fi
else
# no volumes block; append one with redis only (non-destructive to services)
{
echo ""
echo "volumes:"
echo " redis:"
echo " driver: local"
} >> docker-compose.yml
print_status "Added volumes section with Redis"
fi
}
@@ -885,19 +1101,21 @@ migrate_files_to_minio() {
# Function to restart Docker Compose
restart_docker_compose() {
echo -n "Restart Docker Compose now to start MinIO and apply changes? [Y/n]: "
echo -n "Restart now to apply changes and continue the migration? (recommended) [Y/n]: "
local restart_confirm
read -r restart_confirm
restart_confirm=$(echo "$restart_confirm" | tr '[:upper:]' '[:lower:]')
if [[ -z "$restart_confirm" || "$restart_confirm" == "y" ]]; then
print_info "Stopping current services..."
print_info "We need to briefly restart Formbricks to apply the changes."
print_info "Stopping services..."
docker compose down
print_info "Starting services with MinIO..."
docker compose up -d
print_status "Docker Compose restarted successfully!"
return 0
else
print_warning "Skipping restart. You can run 'docker compose down && docker compose up -d' later to start MinIO."
print_warning "Skipping restart for now."
print_info "When you're ready, run: docker compose down && docker compose up -d"
return 1
fi
}
@@ -924,16 +1142,34 @@ wait_for_service_up() {
}
# Main migration function
migrate_to_minio() {
echo "🧱 Formbricks MinIO Migration Script for v4.0"
echo "=============================================="
migrate_to_v4() {
echo "🧱 Formbricks v4.0 Migration"
echo "============================"
echo ""
print_info "We'll prepare your Formbricks instance for v4.0 by:"
print_info "- Adding Redis (for caching)"
print_info "- Adding MinIO (for file storage)"
print_info "- Moving your existing files into MinIO"
print_info "You'll be asked to restart briefly so changes take effect."
echo ""
# Check if we're in the right directory
check_formbricks_directory
# Backup docker-compose.yml before making any changes
backup_docker_compose
# Add Redis configuration first (prerequisite for Formbricks 4.0)
print_status "Setting up Redis..."
add_redis_environment_variables
add_redis_service
add_redis_volume
add_service_dependency "formbricks" "redis"
echo ""
# Abort early if external S3 already configured and no bundled MinIO
external_s3_guard
# Check if MinIO is already configured unless migrating only
if [[ "$MIGRATE_ONLY" != true ]]; then
if [[ "$FORCE_RECONFIGURE" == true ]]; then
@@ -954,8 +1190,8 @@ migrate_to_minio() {
fi
print_info "Detected configuration:"
print_info " Main domain: $main_domain"
print_info " HTTPS setup: $https_setup"
print_info "Main domain: $main_domain"
print_info "HTTPS setup: $https_setup"
echo ""
local files_domain
@@ -984,7 +1220,7 @@ migrate_to_minio() {
exit 1
fi
local default_files_domain="files.$main_domain"
echo -n "Enter the files subdomain for MinIO (e.g., $default_files_domain): "
echo -n "Enter the files subdomain to use for MinIO (e.g., $default_files_domain): "
read files_domain
if [[ -z "$files_domain" ]]; then
files_domain="$default_files_domain"
@@ -998,9 +1234,6 @@ migrate_to_minio() {
generate_minio_credentials
echo ""
# Backup docker-compose.yml
backup_docker_compose
# If reconfiguring/rotating creds, remove existing service blocks so reinjection is clean
if [[ "$REGENERATE_CREDS" == true || "$FORCE_RECONFIGURE" == true ]]; then
remove_minio_services || true
@@ -1013,10 +1246,10 @@ migrate_to_minio() {
add_minio_service "$files_domain" "$main_domain" "$https_setup"
# Add MinIO dependency to formbricks
add_minio_dependency
add_service_dependency "formbricks" "minio-init"
# Update Traefik configuration
update_traefik_config
# Update Traefik configuration (optional - skip if traefik not present)
add_service_dependency "traefik" "minio" "true"
# Add MinIO volume
add_minio_volume
@@ -1034,6 +1267,26 @@ migrate_to_minio() {
if restart_docker_compose; then
restart_success=true
proceed_migration=true
else
# User declined restart; confirm they understand migration cannot proceed without it
print_warning "Without restarting now, the migration cannot proceed."
echo -n "Restart Docker Compose now to proceed with migration? [Y/n]: "
read -r confirm_restart
confirm_restart=$(echo "$confirm_restart" | tr '[:upper:]' '[:lower:]')
if [[ -z "$confirm_restart" || "$confirm_restart" == "y" ]]; then
if restart_docker_compose; then
restart_success=true
proceed_migration=true
else
print_warning "Migration cancelled because restart was declined."
print_info "You can run 'docker compose down && docker compose up -d' later, then rerun this script to migrate files."
return 0
fi
else
print_warning "Migration cancelled at your request. No files were migrated."
print_info "You can restart later and rerun this script to migrate files."
return 0
fi
fi
else
# Try to ensure services are up without full restart
@@ -1138,22 +1391,25 @@ migrate_to_minio() {
fi
echo ""
echo "🎉 MinIO Migration Complete!"
echo "============================="
echo "🎉 Formbricks v4.0 Migration Complete!"
echo "======================================="
echo ""
print_status "MinIO Configuration:"
print_info " Files Domain: $files_domain"
print_info " S3 Access Key: $minio_service_user"
print_info " S3 Bucket: $minio_bucket_name"
print_status "Infrastructure Configuration:"
print_info "Redis://redis:6379"
print_info "Files Domain: $files_domain"
print_info "S3 Access Key: $minio_service_user"
print_info "S3 Bucket: $minio_bucket_name"
echo ""
if [[ "$restart_success" == true ]]; then
print_status "Your Formbricks instance is now using MinIO for file storage!"
print_info "You can check the status with: docker compose ps"
print_info "View logs with: docker compose logs"
print_status "Your Formbricks instance is now ready for v4.0!"
print_info "- Redis is configured for caching"
print_info "- MinIO is configured for file storage"
print_info "To check that everything is running, use: docker compose ps"
print_info "To view logs (for troubleshooting), use: docker compose logs"
else
print_warning "Remember to restart your Docker Compose setup:"
print_info " docker compose down && docker compose up -d"
print_warning "Before migration can happen, please remember to restart your services:"
print_info "docker compose down && docker compose up -d"
fi
if [[ "$MIGRATE_ONLY" != true ]]; then
@@ -1211,6 +1467,6 @@ cleanup_uploads_from_compose() {
fi
# Check if script is being run directly
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
migrate_to_minio
if [[ -n "${BASH_SOURCE:-}" && "${BASH_SOURCE[0]}" == "${0}" ]]; then
migrate_to_v4
fi

View File

@@ -133,11 +133,13 @@ REDIS_URL=redis://your-redis-host:6379
Configure S3 storage by adding the following environment variables to your instances:
```sh env
# Required for file uploads in serverless environments
# Required
S3_BUCKET_NAME=your-bucket-name
# Optional - if not provided, AWS SDK will use defaults (us-east-1) or auto-detect
S3_ACCESS_KEY=your-access-key
S3_SECRET_KEY=your-secret-key
S3_REGION=your-region
S3_BUCKET_NAME=your-bucket-name
# For S3-compatible storage (e.g., StorJ, MinIO)
# Leave empty for Amazon S3

View File

@@ -310,6 +310,18 @@ To restart Formbricks, simply run the following command:
The script will automatically restart all the Formbricks related containers and brings the entire stack up with the previous configuration.
## Cleanup MinIO init (optional)
During the one-click setup, a temporary `minio-init` service configures MinIO (bucket, policy, service user). It is idempotent and safe to leave in place; it will do nothing on subsequent starts once configuration exists.
If you prefer to remove the `minio-init` service and its references after a successful setup, run:
```
./formbricks.sh cleanup-minio-init
```
This only removes the init job and its Compose references; it does not delete any data or affect your MinIO configuration.
## Uninstall
To uninstall Formbricks, simply run the following command, but keep in mind that this will delete all your data!

View File

@@ -227,8 +227,8 @@ describe("client.ts", () => {
}
});
test("should return error when region is missing", async () => {
// Mock constants with missing region
test("should create S3 client when region is missing (uses AWS SDK defaults)", async () => {
// Mock constants with missing region - should still work
vi.doMock("./constants", () => ({
...mockConstants,
S3_REGION: undefined,
@@ -238,9 +238,44 @@ describe("client.ts", () => {
const result = createS3ClientFromEnv();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("s3_credentials_error");
expect(mockS3Client).toHaveBeenCalledWith({
credentials: {
accessKeyId: mockConstants.S3_ACCESS_KEY,
secretAccessKey: mockConstants.S3_SECRET_KEY,
},
endpoint: mockConstants.S3_ENDPOINT_URL,
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBeDefined();
}
});
test("should create S3 client with only bucket name (minimal config for IAM roles)", async () => {
// Mock constants with only bucket name - minimal required config
vi.doMock("./constants", () => ({
S3_ACCESS_KEY: undefined,
S3_SECRET_KEY: undefined,
S3_REGION: undefined,
S3_BUCKET_NAME: "test-bucket",
S3_ENDPOINT_URL: undefined,
S3_FORCE_PATH_STYLE: false,
}));
const { createS3ClientFromEnv } = await import("./client");
const result = createS3ClientFromEnv();
expect(mockS3Client).toHaveBeenCalledWith({
endpoint: undefined,
forcePathStyle: false,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBeDefined();
}
});
@@ -318,10 +353,9 @@ describe("client.ts", () => {
});
test("should return undefined when creating from env fails and no client provided", async () => {
// Mock constants with missing required fields (region and bucket)
// Mock constants with missing required field (bucket name only)
vi.doMock("./constants", () => ({
...mockConstants,
S3_REGION: undefined,
S3_BUCKET_NAME: undefined,
}));
@@ -354,7 +388,6 @@ describe("client.ts", () => {
test("returns undefined when env is invalid and does not construct client", async () => {
vi.doMock("./constants", () => ({
...mockConstants,
S3_REGION: undefined,
S3_BUCKET_NAME: undefined,
}));

View File

@@ -19,9 +19,9 @@ let cachedS3Client: S3Client | undefined;
*/
export const createS3ClientFromEnv = (): Result<S3Client, StorageError> => {
try {
// S3_REGION and S3_BUCKET_NAME are always required
if (!S3_BUCKET_NAME || !S3_REGION) {
logger.error("S3 Client: S3_REGION and S3_BUCKET_NAME are required");
// Only S3_BUCKET_NAME is required - S3_REGION is optional and will default to AWS SDK defaults
if (!S3_BUCKET_NAME) {
logger.error("S3 Client: S3_BUCKET_NAME is required");
return err({
code: StorageErrorCode.S3CredentialsError,
});
@@ -29,11 +29,15 @@ export const createS3ClientFromEnv = (): Result<S3Client, StorageError> => {
// Build S3 client configuration
const s3Config: S3ClientConfig = {
region: S3_REGION,
endpoint: S3_ENDPOINT_URL,
forcePathStyle: S3_FORCE_PATH_STYLE,
};
// Only set region if it's provided, otherwise let AWS SDK use its defaults
if (S3_REGION) {
s3Config.region = S3_REGION;
}
// Only add credentials if both access key and secret key are provided
// This allows the AWS SDK to use IAM roles, instance profiles, or other credential providers
if (S3_ACCESS_KEY && S3_SECRET_KEY) {