fix: updated encryption algorithm and added sort function (#5485)

Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
victorvhs017
2025-04-24 14:47:22 +07:00
committed by GitHub
parent a5958d5653
commit 3f16291137
11 changed files with 242 additions and 47 deletions

View File

@@ -22,6 +22,10 @@ export const GET = async (
return responses.unauthorizedResponse();
}
if (survey.type !== "link") {
return responses.badRequestResponse("Single use links are only available for link surveys");
}
if (!survey.singleUse || !survey.singleUse.enabled) {
return responses.badRequestResponse("Single use links are not enabled for this survey");
}

View File

@@ -7,7 +7,6 @@ import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUs
vi.mock("@/lib/crypto", () => ({
symmetricEncrypt: vi.fn(),
symmetricDecrypt: vi.fn(),
decryptAES128: vi.fn(),
}));
// Mock constants

View File

@@ -0,0 +1,59 @@
import { createCipheriv, randomBytes } from "crypto";
import { describe, expect, test, vi } from "vitest";
import {
generateLocalSignedUrl,
getHash,
symmetricDecrypt,
symmetricEncrypt,
validateLocalSignedUrl,
} from "./crypto";
vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) }));
const key = "0".repeat(32);
const plain = "hello";
describe("crypto", () => {
test("encrypt + decrypt roundtrip", () => {
const cipher = symmetricEncrypt(plain, key);
expect(symmetricDecrypt(cipher, key)).toBe(plain);
});
test("decrypt V2 GCM payload", () => {
const iv = randomBytes(16);
const bufKey = Buffer.from(key, "utf8");
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
let enc = cipher.update(plain, "utf8", "hex");
enc += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
const payload = `${iv.toString("hex")}:${enc}:${tag}`;
expect(symmetricDecrypt(payload, key)).toBe(plain);
});
test("decrypt legacy (single-colon) payload", () => {
const iv = randomBytes(16);
const cipher = createCipheriv("aes256", Buffer.from(key, "utf8"), iv); // NOSONAR typescript:S5542 // We are testing backwards compatibility
let enc = cipher.update(plain, "utf8", "hex");
enc += cipher.final("hex");
const legacy = `${iv.toString("hex")}:${enc}`;
expect(symmetricDecrypt(legacy, key)).toBe(plain);
});
test("getHash returns a non-empty string", () => {
const h = getHash("abc");
expect(typeof h).toBe("string");
expect(h.length).toBeGreaterThan(0);
});
test("signed URL generation & validation", () => {
const { uuid, timestamp, signature } = generateLocalSignedUrl("f", "e", "t");
expect(uuid).toHaveLength(32);
expect(typeof timestamp).toBe("number");
expect(typeof signature).toBe("string");
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, signature, key)).toBe(true);
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, "bad", key)).toBe(false);
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp - 1000 * 60 * 6, signature, key)).toBe(
false
);
});
});

View File

@@ -1,11 +1,12 @@
import crypto from "crypto";
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "crypto";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY } from "./constants";
const ALGORITHM = "aes256";
const ALGORITHM_V1 = "aes256";
const ALGORITHM_V2 = "aes-256-gcm";
const INPUT_ENCODING = "utf8";
const OUTPUT_ENCODING = "hex";
const BUFFER_ENCODING = ENCRYPTION_KEY!.length === 32 ? "latin1" : "hex";
const BUFFER_ENCODING = ENCRYPTION_KEY.length === 32 ? "latin1" : "hex";
const IV_LENGTH = 16; // AES blocksize
/**
@@ -17,15 +18,12 @@ const IV_LENGTH = 16; // AES blocksize
*/
export const symmetricEncrypt = (text: string, key: string) => {
const _key = Buffer.from(key, BUFFER_ENCODING);
const iv = crypto.randomBytes(IV_LENGTH);
// @ts-ignore -- the package needs to be built
const cipher = crypto.createCipheriv(ALGORITHM, _key, iv);
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM_V2, _key, iv);
let ciphered = cipher.update(text, INPUT_ENCODING, OUTPUT_ENCODING);
ciphered += cipher.final(OUTPUT_ENCODING);
const ciphertext = iv.toString(OUTPUT_ENCODING) + ":" + ciphered;
return ciphertext;
const tag = cipher.getAuthTag().toString(OUTPUT_ENCODING);
return `${iv.toString(OUTPUT_ENCODING)}:${ciphered}:${tag}`;
};
/**
@@ -33,38 +31,68 @@ export const symmetricEncrypt = (text: string, key: string) => {
* @param text Value to decrypt
* @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm
*/
export const symmetricDecrypt = (text: string, key: string) => {
const symmetricDecryptV1 = (text: string, key: string): string => {
const _key = Buffer.from(key, BUFFER_ENCODING);
const components = text.split(":");
const iv_from_ciphertext = Buffer.from(components.shift() || "", OUTPUT_ENCODING);
// @ts-ignore -- the package needs to be built
const decipher = crypto.createDecipheriv(ALGORITHM, _key, iv_from_ciphertext);
const iv_from_ciphertext = Buffer.from(components.shift() ?? "", OUTPUT_ENCODING);
const decipher = createDecipheriv(ALGORITHM_V1, _key, iv_from_ciphertext);
let deciphered = decipher.update(components.join(":"), OUTPUT_ENCODING, INPUT_ENCODING);
deciphered += decipher.final(INPUT_ENCODING);
return deciphered;
};
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
/**
*
* @param text Value to decrypt
* @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm
*/
// create an aes128 encryption function
export const encryptAES128 = (encryptionKey: string, data: string): string => {
// @ts-ignore -- the package needs to be built
const cipher = createCipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
let encrypted = cipher.update(data, "utf-8", "hex");
encrypted += cipher.final("hex");
return encrypted;
};
// create an aes128 decryption function
export const decryptAES128 = (encryptionKey: string, data: string): string => {
// @ts-ignore -- the package needs to be built
const cipher = createDecipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
let decrypted = cipher.update(data, "hex", "utf-8");
decrypted += cipher.final("utf-8");
const symmetricDecryptV2 = (text: string, key: string): string => {
// split into [ivHex, encryptedHex, tagHex]
const [ivHex, encryptedHex, tagHex] = text.split(":");
const _key = Buffer.from(key, BUFFER_ENCODING);
const iv = Buffer.from(ivHex, OUTPUT_ENCODING);
const decipher = createDecipheriv(ALGORITHM_V2, _key, iv);
decipher.setAuthTag(Buffer.from(tagHex, OUTPUT_ENCODING));
let decrypted = decipher.update(encryptedHex, OUTPUT_ENCODING, INPUT_ENCODING);
decrypted += decipher.final(INPUT_ENCODING);
return decrypted;
};
/**
* Decrypts an encrypted payload, automatically handling multiple encryption versions.
*
* If the payload contains exactly one “:”, it is treated as a legacy V1 format
* and `symmetricDecryptV1` is invoked. Otherwise, it attempts a V2 GCM decryption
* via `symmetricDecryptV2`, falling back to V1 on failure (e.g., authentication
* errors or bad formats).
*
* @param payload - The encrypted string to decrypt.
* @param key - The secret key used for decryption.
* @returns The decrypted plaintext.
*/
export function symmetricDecrypt(payload: string, key: string): string {
// If it's clearly V1 (only one “:”), skip straight to V1
if (payload.split(":").length === 2) {
return symmetricDecryptV1(payload, key);
}
// Otherwise try GCM first, then fall back to CBC
try {
return symmetricDecryptV2(payload, key);
} catch (err) {
logger.warn("AES-GCM decryption failed; refusing to fall back to insecure CBC", err);
throw err;
}
}
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
export const generateLocalSignedUrl = (
fileName: string,
environmentId: string,
@@ -73,7 +101,7 @@ export const generateLocalSignedUrl = (
const uuid = randomBytes(16).toString("hex");
const timestamp = Date.now();
const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`;
const signature = createHmac("sha256", ENCRYPTION_KEY!).update(data).digest("hex");
const signature = createHmac("sha256", ENCRYPTION_KEY).update(data).digest("hex");
return { signature, uuid, timestamp };
};

View File

@@ -0,0 +1,103 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { TTemplate, TTemplateFilter } from "@formbricks/types/templates";
import { TemplateTags, getRoleBasedStyling } from "./template-tags";
vi.mock("../lib/utils", () => ({
getRoleMapping: () => [{ value: "marketing", label: "Marketing" }],
getChannelMapping: () => [
{ value: "email", label: "Email Survey" },
{ value: "chat", label: "Chat Survey" },
{ value: "sms", label: "SMS Survey" },
],
getIndustryMapping: () => [
{ value: "indA", label: "Industry A" },
{ value: "indB", label: "Industry B" },
],
}));
const baseTemplate = {
role: "marketing",
channels: ["email"],
industries: ["indA"],
preset: { questions: [] },
} as unknown as TTemplate;
const noFilter: TTemplateFilter[] = [null, null];
describe("TemplateTags", () => {
afterEach(() => {
cleanup();
});
test("getRoleBasedStyling for productManager", () => {
expect(getRoleBasedStyling("productManager")).toBe("border-blue-300 bg-blue-50 text-blue-500");
});
test("getRoleBasedStyling for sales", () => {
expect(getRoleBasedStyling("sales")).toBe("border-emerald-300 bg-emerald-50 text-emerald-500");
});
test("getRoleBasedStyling for customerSuccess", () => {
expect(getRoleBasedStyling("customerSuccess")).toBe("border-violet-300 bg-violet-50 text-violet-500");
});
test("getRoleBasedStyling for peopleManager", () => {
expect(getRoleBasedStyling("peopleManager")).toBe("border-pink-300 bg-pink-50 text-pink-500");
});
test("getRoleBasedStyling default case", () => {
expect(getRoleBasedStyling(undefined)).toBe("border-slate-300 bg-slate-50 text-slate-500");
});
test("renders role tag with correct styling and label", () => {
render(<TemplateTags template={baseTemplate} selectedFilter={noFilter} />);
const role = screen.getByText("Marketing");
expect(role).toHaveClass("border-orange-300", "bg-orange-50", "text-orange-500");
});
test("single channel shows label without suffix", () => {
render(<TemplateTags template={baseTemplate} selectedFilter={noFilter} />);
expect(screen.getByText("Email Survey")).toBeInTheDocument();
});
test("two channels concatenated with 'common.or'", () => {
const tpl = { ...baseTemplate, channels: ["email", "chat"] } as unknown as TTemplate;
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
expect(screen.getByText("Chat common.or Email")).toBeInTheDocument();
});
test("three channels shows 'environments.surveys.templates.all_channels'", () => {
const tpl = { ...baseTemplate, channels: ["email", "chat", "sms"] } as unknown as TTemplate;
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
expect(screen.getByText("environments.surveys.templates.all_channels")).toBeInTheDocument();
});
test("more than three channels hides channel tag", () => {
const tpl = { ...baseTemplate, channels: ["email", "chat", "sms", "email"] } as unknown as TTemplate;
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
expect(screen.queryByText(/Survey|common\.or|all_channels/)).toBeNull();
});
test("single industry shows mapped label", () => {
render(<TemplateTags template={baseTemplate} selectedFilter={noFilter} />);
expect(screen.getByText("Industry A")).toBeInTheDocument();
});
test("multiple industries shows 'multiple_industries'", () => {
const tpl = { ...baseTemplate, industries: ["indA", "indB"] } as unknown as TTemplate;
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
expect(screen.getByText("environments.surveys.templates.multiple_industries")).toBeInTheDocument();
});
test("selectedFilter[1] overrides industry tag", () => {
render(<TemplateTags template={baseTemplate} selectedFilter={[null, "marketing"]} />);
expect(screen.getByText("Marketing")).toBeInTheDocument();
});
test("renders branching logic icon when questions have logic", () => {
const tpl = { ...baseTemplate, preset: { questions: [{ logic: [1] }] } } as unknown as TTemplate;
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
expect(document.querySelector("svg")).toBeInTheDocument();
});
});

View File

@@ -2,8 +2,7 @@
import { cn } from "@/lib/cn";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { TFnType } from "@tolgee/react";
import { TFnType, useTranslate } from "@tolgee/react";
import { SplitIcon } from "lucide-react";
import { useMemo } from "react";
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
@@ -17,7 +16,7 @@ interface TemplateTagsProps {
type NonNullabeChannel = NonNullable<TProjectConfigChannel>;
const getRoleBasedStyling = (role: TTemplateRole | undefined): string => {
export const getRoleBasedStyling = (role: TTemplateRole | undefined): string => {
switch (role) {
case "productManager":
return "border-blue-300 bg-blue-50 text-blue-500";
@@ -44,7 +43,8 @@ const getChannelTag = (channels: NonNullabeChannel[] | undefined, t: TFnType): s
if (label) return t(label);
return undefined;
})
.sort();
.filter((label): label is string => !!label)
.sort((a, b) => a.localeCompare(b));
const removeSurveySuffix = (label: string | undefined) => label?.replace(" Survey", "");

View File

@@ -17,7 +17,7 @@
.react-calendar__month-view__weekdays {
text-decoration-style: dotted !important;
text-decoration: underline;
text-decoration-line: underline !important;
}
.react-calendar__month-view__days__day--weekend {

View File

@@ -59,6 +59,7 @@ export default defineConfig({
"modules/organization/settings/api-keys/components/*.tsx",
"modules/survey/hooks/*.tsx",
"modules/survey/components/question-form-input/index.tsx",
"modules/survey/components/template-list/components/template-tags.tsx",
"modules/survey/lib/client-utils.ts",
"modules/survey/list/components/survey-card.tsx",
"modules/survey/list/components/survey-dropdown-menu.tsx",
@@ -74,6 +75,7 @@ export default defineConfig({
"modules/analysis/**/*.tsx",
"modules/analysis/**/*.ts",
"modules/survey/editor/components/end-screen-form.tsx",
"lib/crypto.ts",
"lib/utils/billing.ts"
],
exclude: [

View File

@@ -207,7 +207,7 @@ export const ZResponseFilterCriteria = z.object({
export const ZResponseContact = z.object({
id: ZId,
userId: z.string(),
userId: z.string().optional(),
});
export type TResponseContact = z.infer<typeof ZResponseContact>;

View File

@@ -2409,7 +2409,7 @@ export const ZSurveyQuestionSummaryOpenText = z.object({
contact: z
.object({
id: ZId,
userId: z.string(),
userId: z.string().optional(),
})
.nullable(),
contactAttributes: ZContactAttributes.nullable(),
@@ -2444,7 +2444,7 @@ export const ZSurveyQuestionSummaryMultipleChoice = z.object({
contact: z
.object({
id: ZId,
userId: z.string(),
userId: z.string().optional(),
})
.nullable(),
contactAttributes: ZContactAttributes.nullable(),
@@ -2562,7 +2562,7 @@ export const ZSurveyQuestionSummaryDate = z.object({
contact: z
.object({
id: ZId,
userId: z.string(),
userId: z.string().optional(),
})
.nullable(),
contactAttributes: ZContactAttributes.nullable(),
@@ -2584,7 +2584,7 @@ export const ZSurveyQuestionSummaryFileUpload = z.object({
contact: z
.object({
id: ZId,
userId: z.string(),
userId: z.string().optional(),
})
.nullable(),
contactAttributes: ZContactAttributes.nullable(),
@@ -2641,7 +2641,7 @@ export const ZSurveyQuestionSummaryHiddenFields = z.object({
contact: z
.object({
id: ZId,
userId: z.string(),
userId: z.string().optional(),
})
.nullable(),
contactAttributes: ZContactAttributes.nullable(),
@@ -2663,7 +2663,7 @@ export const ZSurveyQuestionSummaryAddress = z.object({
contact: z
.object({
id: ZId,
userId: z.string(),
userId: z.string().optional(),
})
.nullable(),
contactAttributes: ZContactAttributes.nullable(),
@@ -2685,7 +2685,7 @@ export const ZSurveyQuestionSummaryContactInfo = z.object({
contact: z
.object({
id: ZId,
userId: z.string(),
userId: z.string().optional(),
})
.nullable(),
contactAttributes: ZContactAttributes.nullable(),
@@ -2711,7 +2711,7 @@ export const ZSurveyQuestionSummaryRanking = z.object({
contact: z
.object({
id: ZId,
userId: z.string(),
userId: z.string().optional(),
})
.nullable(),
contactAttributes: ZContactAttributes.nullable(),

View File

@@ -21,5 +21,5 @@ sonar.scm.exclusions.disabled=false
sonar.sourceEncoding=UTF-8
# Coverage
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/*.stories.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/*.stories.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/*.stories.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/*.stories.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css