Compare commits

..

1 Commits

Author SHA1 Message Date
Dhruwang
6710f1d9a9 fix: eliminate inline style attributes for CSP nonce compliance
Inline `style` attributes on HTML elements violate strict CSP `style-src`
policies because nonces can only be applied to `<style>` tags, not element
attributes. This replaces all inline style props across the surveys and
survey-ui packages with CSP-compatible alternatives:

- Static values → CSS classes (fb-full-size, fb-translate-x-center, etc.)
- CSS variable references → stylesheet rules (button-custom, fb-text-input-size)
- Dynamic values → CSSOM via element.style.setProperty(), which bypasses
  CSP style-src restrictions as the browser treats it as script-driven DOM
  manipulation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:08:49 +05:30
18 changed files with 238 additions and 88 deletions

View File

@@ -92,11 +92,7 @@ function Consent({
disabled={disabled}
aria-invalid={Boolean(errorMessage)}
/>
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
<span
className="font-input-weight text-input-text flex-1"
style={{ fontSize: "var(--fb-input-font-size)" }}
dir={dir}>
<span className="fb-text-input-size font-input-weight text-input-text flex-1" dir={dir}>
{checkboxLabel}
</span>
</label>

View File

@@ -96,8 +96,7 @@ function UploadedFileItem({
<div className="flex flex-col items-center justify-center p-2">
<UploadIcon />
<p
style={{ fontSize: "var(--fb-input-font-size)" }}
className="mt-1 w-full overflow-hidden px-2 text-center overflow-ellipsis whitespace-nowrap text-[var(--foreground)]"
className="fb-text-input-size mt-1 w-full overflow-hidden px-2 text-center overflow-ellipsis whitespace-nowrap text-[var(--foreground)]"
title={file.name}>
{file.name}
</p>
@@ -188,10 +187,7 @@ function UploadArea({
)}
aria-label="Upload files by clicking or dragging them here">
<Upload className="text-input-text h-6" aria-hidden="true" />
<span
className="text-input-text font-input-weight m-2"
style={{ fontSize: "var(--fb-input-font-size)" }}
id={`${inputId}-label`}>
<span className="fb-text-input-size text-input-text font-input-weight m-2" id={`${inputId}-label`}>
{placeholderText}
</span>
<input
@@ -306,11 +302,7 @@ function FileUpload({
<div className="w-full">
{isUploading ? (
<div className="flex animate-pulse items-center justify-center rounded-lg py-4">
<p
className="text-muted-foreground font-medium"
style={{ fontSize: "var(--fb-input-font-size)" }}>
{uploadingText}
</p>
<p className="fb-text-input-size text-muted-foreground font-medium">{uploadingText}</p>
</div>
) : null}

View File

@@ -56,8 +56,7 @@ function ElementMedia({ imgUrl, videoUrl, altText = "Image" }: Readonly<ElementM
<iframe
src={videoUrlWithParams}
title="Question video"
style={{ border: 0 }}
className={cn("aspect-video w-full rounded-md", isLoading ? "opacity-0" : "")}
className={cn("aspect-video w-full rounded-md border-0", isLoading ? "opacity-0" : "")}
onLoad={() => {
setIsLoading(false);
}}

View File

@@ -19,8 +19,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
type={type}
dir={dir}
data-slot="input"
style={{ fontSize: "var(--fb-input-font-size)" }}
className={cn(
"fb-text-input-size",
// Layout and behavior
"flex min-w-0 border transition-[color,box-shadow] outline-none",
// Customizable styles via CSS variables (using Tailwind theme extensions)

View File

@@ -8,6 +8,14 @@ export interface ProgressProps extends Omit<React.ComponentProps<"div">, "childr
function Progress({ className, value, ...props }: Readonly<ProgressProps>): React.JSX.Element {
const progressValue: number = typeof value === "number" ? value : 0;
const indicatorRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (indicatorRef.current) {
indicatorRef.current.style.setProperty("transform", `translateX(-${String(100 - progressValue)}%)`);
}
}, [progressValue]);
return (
<ProgressPrimitive.Root
data-slot="progress"
@@ -15,11 +23,9 @@ function Progress({ className, value, ...props }: Readonly<ProgressProps>): Reac
className={cn("progress-track relative w-full overflow-hidden", className)}
{...props}>
<ProgressPrimitive.Indicator
ref={indicatorRef}
data-slot="progress-indicator"
className="progress-indicator h-full w-full flex-1 transition-all"
style={{
transform: `translateX(-${String(100 - progressValue)}%)`,
}}
/>
</ProgressPrimitive.Root>
);

View File

@@ -10,9 +10,9 @@ function Textarea({ className, dir = "auto", ...props }: TextareaProps): React.J
<div className="relative space-y-2">
<textarea
data-slot="textarea"
style={{ fontSize: "var(--fb-input-font-size)" }}
dir={dir}
className={cn(
"fb-text-input-size",
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}

View File

@@ -221,6 +221,13 @@
Textarea Scrollbar Styling
Smaller, more subtle scrollbars for textarea elements.
--------------------------------------------------------------------------- */
/* CSP-safe utility: font-size from --fb-input-font-size variable.
Cannot use Tailwind `text-input` because the `input` token is ambiguous
between fontSize.input and colors.input in this config. */
#fbjs .fb-text-input-size {
font-size: var(--fb-input-font-size);
}
#fbjs textarea {
/* Firefox */
scrollbar-width: thin;

View File

@@ -77,18 +77,6 @@ export function SubmitButton({
"border-submit-button-border focus:ring-focus mb-1 flex items-center justify-center border leading-4 shadow-xs hover:opacity-90 focus:ring-2 focus:ring-offset-2 focus:outline-hidden",
"button-custom"
)}
style={{
borderRadius: "var(--fb-button-border-radius)",
backgroundColor: "var(--fb-button-bg-color)",
color: "var(--fb-button-text-color)",
height: "var(--fb-button-height)",
fontSize: "var(--fb-button-font-size)",
fontWeight: "var(--fb-button-font-weight)",
paddingLeft: "var(--fb-button-padding-x)",
paddingRight: "var(--fb-button-padding-x)",
paddingTop: "var(--fb-button-padding-y)",
paddingBottom: "var(--fb-button-padding-y)",
}}
onClick={onClick}
disabled={disabled}>
{buttonLabel || (isLastQuestion ? t("common.finish") : t("common.next"))}

View File

@@ -1,3 +1,5 @@
import { applyCsomStyles } from "@/lib/use-csom-style";
interface AutoCloseProgressBarProps {
autoCloseTimeout: number;
}
@@ -7,10 +9,12 @@ export function AutoCloseProgressBar({ autoCloseTimeout }: AutoCloseProgressBarP
<div className="bg-accent-bg h-2 w-full overflow-hidden">
<div
key={autoCloseTimeout}
className="bg-brand z-20 h-2"
style={{
animation: `shrink-width-to-zero ${autoCloseTimeout.toString()}s linear forwards`,
width: "100%",
className="bg-brand z-20 h-2 w-full"
ref={(el) => {
if (el)
applyCsomStyles(el, {
animation: `shrink-width-to-zero ${autoCloseTimeout.toString()}s linear forwards`,
});
}}
/>
</div>

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from "preact/hooks";
import { useEffect, useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TSurveyLanguage } from "@formbricks/types/surveys/types";
@@ -8,6 +8,7 @@ import { getI18nLanguage } from "@/lib/i18n-utils";
import i18n from "@/lib/i18n.config";
import { getLanguageDisplayName } from "@/lib/language-display-name";
import { useClickOutside } from "@/lib/use-click-outside-hook";
import { applyCsomStyles } from "@/lib/use-csom-style";
import { cn, isRTLLanguage } from "@/lib/utils";
interface LanguageSwitchProps {
@@ -35,6 +36,21 @@ export function LanguageSwitch({
const hoverColorWithOpacity = hoverColor ?? mixColor("#000000", "#ffffff", 0.8);
const [isHovered, setIsHovered] = useState(false);
const switchBtnRef = useRef<HTMLButtonElement>(null);
const applyHoverStyles = (el: HTMLButtonElement | null, hovered: boolean) => {
if (!el) return;
applyCsomStyles(el, {
backgroundColor: hovered ? hoverColorWithOpacity : "transparent",
transition: "background-color 0.2s ease",
borderRadius: typeof borderRadius === "number" ? `${borderRadius}px` : borderRadius,
});
};
// Keep styles in sync when props change
useEffect(() => {
applyHoverStyles(switchBtnRef.current, isHovered);
}, [hoverColorWithOpacity, borderRadius]);
const [showLanguageDropdown, setShowLanguageDropdown] = useState(false);
const toggleDropdown = () => {
@@ -79,21 +95,26 @@ export function LanguageSwitch({
<button
title={t("common.language_switch")}
type="button"
ref={(el) => {
(switchBtnRef as { current: HTMLButtonElement | null }).current = el;
applyHoverStyles(el, isHovered);
}}
className={cn(
"text-heading relative flex h-8 w-8 items-center justify-center rounded-md focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
)}
style={{
backgroundColor: isHovered ? hoverColorWithOpacity : "transparent",
transition: "background-color 0.2s ease",
borderRadius: typeof borderRadius === "number" ? `${borderRadius}px` : borderRadius,
}}
onClick={toggleDropdown}
tabIndex={-1}
aria-haspopup="true"
aria-expanded={showLanguageDropdown}
aria-label={t("common.language_switch")}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
onMouseEnter={() => {
setIsHovered(true);
applyHoverStyles(switchBtnRef.current, true);
}}
onMouseLeave={() => {
setIsHovered(false);
applyHoverStyles(switchBtnRef.current, false);
}}>
<LanguageIcon />
</button>
{showLanguageDropdown ? (

View File

@@ -1,10 +1,12 @@
import { applyCsomStyles } from "@/lib/use-csom-style";
export function Progress({ progress }: { progress: number }) {
return (
<div className="progress-track h-2 w-full overflow-hidden rounded-none">
<div
className="transition-width progress-indicator z-20 h-full duration-500"
style={{
width: `${Math.floor(progress * 100).toString()}%`,
ref={(el) => {
if (el) applyCsomStyles(el, { width: `${Math.floor(progress * 100).toString()}%` });
}}
/>
</div>

View File

@@ -1,7 +1,8 @@
import { useState } from "preact/hooks";
import { useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { CloseIcon } from "@/components/icons/close-icon";
import { mixColor } from "@/lib/color";
import { applyCsomStyles } from "@/lib/use-csom-style";
import { cn } from "@/lib/utils";
interface SurveyCloseButtonProps {
@@ -14,19 +15,34 @@ export function SurveyCloseButton({ onClose, hoverColor, borderRadius }: Readonl
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const hoverColorWithOpacity = hoverColor ?? mixColor("#000000", "#ffffff", 0.8);
const btnRef = useRef<HTMLButtonElement>(null);
const applyStyles = (el: HTMLButtonElement | null, hovered: boolean) => {
if (!el) return;
applyCsomStyles(el, {
backgroundColor: hovered ? hoverColorWithOpacity : "transparent",
transition: "background-color 0.2s ease",
borderRadius: typeof borderRadius === "number" ? `${borderRadius}px` : borderRadius,
});
};
return (
<div className="z-1001 flex w-fit items-center">
<button
type="button"
onClick={onClose}
style={{
backgroundColor: isHovered ? hoverColorWithOpacity : "transparent",
transition: "background-color 0.2s ease",
borderRadius: typeof borderRadius === "number" ? `${borderRadius}px` : borderRadius,
ref={(el) => {
(btnRef as { current: HTMLButtonElement | null }).current = el;
applyStyles(el, isHovered);
}}
onClick={onClose}
onMouseEnter={() => {
setIsHovered(true);
applyStyles(btnRef.current, true);
}}
onMouseLeave={() => {
setIsHovered(false);
applyStyles(btnRef.current, false);
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={cn(
"text-heading relative flex h-8 w-8 items-center justify-center p-2 focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
)}

View File

@@ -72,17 +72,19 @@ export const ScrollableContainer = forwardRef<ScrollableContainerHandle, Scrolla
maxHeight = "60dvh";
}
// Apply maxHeight via CSSOM to avoid inline style attributes (CSP compliance)
useEffect(() => {
if (containerRef.current) {
containerRef.current.style.setProperty("max-height", maxHeight);
}
}, [maxHeight]);
return (
<div className="relative">
{!isAtTop && (
<div className="from-survey-bg absolute top-0 right-2 left-0 z-10 h-4 bg-linear-to-b to-transparent" />
)}
<div
ref={containerRef}
style={{
maxHeight,
}}
className={cn("bg-survey-bg overflow-auto px-4")}>
<div ref={containerRef} className={cn("bg-survey-bg overflow-auto px-4")}>
{children}
</div>
{!isAtBottom && (
@@ -91,8 +93,7 @@ export const ScrollableContainer = forwardRef<ScrollableContainerHandle, Scrolla
<button
type="button"
onClick={scrollToBottom}
style={{ transform: "translateX(-50%)" }}
className="bg-survey-bg hover:border-border focus:ring-brand absolute bottom-2 left-1/2 z-20 flex h-8 w-8 items-center justify-center rounded-full border border-transparent shadow-lg transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
className="fb-translate-x-center bg-survey-bg hover:border-border focus:ring-brand absolute bottom-2 left-1/2 z-20 flex h-8 w-8 items-center justify-center rounded-full border border-transparent shadow-lg transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
aria-label="Scroll to bottom">
<ChevronDownIcon className="text-heading h-5 w-5" />
</button>

View File

@@ -1,10 +1,11 @@
import { MutableRef } from "preact/hooks";
import { useEffect, useMemo, useState } from "preact/hooks";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { JSX } from "preact/jsx-runtime";
import React from "react";
import { type TPlacement } from "@formbricks/types/common";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TCardArrangementOptions } from "@formbricks/types/styling";
import { applyCsomStyles } from "@/lib/use-csom-style";
interface StackedCardProps {
cardRefs: MutableRef<(HTMLDivElement | null)[]>;
@@ -54,7 +55,14 @@ export const StackedCard = ({
};
const getDummyCardContent = () => {
return <div style={{ height: cardHeight }} className="w-full p-6"></div>;
return (
<div
ref={(el) => {
if (el) applyCsomStyles(el, { height: cardHeight });
}}
className="w-full p-6"
/>
);
};
const calculateCardTransform = useMemo(() => {
@@ -90,6 +98,45 @@ export const StackedCard = ({
}
: {};
// Collect all dynamic styles for the card element
const cardStyles = useMemo(
() => ({
zIndex: `${(1000 - dynamicQuestionIndex).toString()}`,
transform: calculateCardTransform(offset),
opacity: `${isHidden ? 0 : (100 - 20 * offset) / 100}`,
height: fullSizeCards ? "100%" : currentCardHeight,
pointerEvents: offset === 0 ? "auto" : "none",
...borderStyles,
...straightCardArrangementStyles,
...getTopBottomStyles(),
}),
[
dynamicQuestionIndex,
calculateCardTransform,
offset,
isHidden,
fullSizeCards,
currentCardHeight,
borderStyles,
straightCardArrangementStyles,
]
);
const contentRef = useRef<HTMLDivElement>(null);
// Apply card styles via CSSOM whenever they change
useEffect(() => {
const el = cardRefs.current[dynamicQuestionIndex];
if (el) applyCsomStyles(el, cardStyles);
}, [cardStyles, dynamicQuestionIndex]);
// Apply content opacity via CSSOM whenever it changes
useEffect(() => {
if (contentRef.current) {
contentRef.current.style.setProperty("opacity", `${contentOpacity}`);
}
}, [contentOpacity]);
useEffect(() => {
setTimeout(() => {
setDelayedOffset(offset);
@@ -107,28 +154,13 @@ export const StackedCard = ({
<div
ref={(el) => {
cardRefs.current[dynamicQuestionIndex] = el;
if (el) applyCsomStyles(el, cardStyles);
}}
id={`questionCard-${dynamicQuestionIndex}`}
data-testid={`questionCard-${dynamicQuestionIndex}`}
key={dynamicQuestionIndex}
style={{
zIndex: 1000 - dynamicQuestionIndex,
transform: calculateCardTransform(offset),
opacity: isHidden ? 0 : (100 - 20 * offset) / 100,
height: fullSizeCards ? "100%" : currentCardHeight,
transition: "transform 600ms ease-in-out, opacity 600ms ease-in-out, width 600ms ease-in-out",
pointerEvents: offset === 0 ? "auto" : "none",
...borderStyles,
...straightCardArrangementStyles,
...getTopBottomStyles(),
}}
className="pointer rounded-custom bg-survey-bg absolute inset-x-0 overflow-hidden">
<div
style={{
opacity: contentOpacity,
transition: "opacity 300ms ease-in-out",
height: "100%",
}}>
className="pointer rounded-custom bg-survey-bg fb-card-transition absolute inset-x-0 overflow-hidden">
<div className="fb-content-transition" ref={contentRef}>
{delayedOffset === 0 ? getCardContent(dynamicQuestionIndex, offset) : getDummyCardContent()}
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TProjectStyling } from "@formbricks/types/project";
import { type TCardArrangementOptions } from "@formbricks/types/styling";
import { TSurveyStyling } from "@formbricks/types/surveys/types";
import { applyCsomStyles } from "@/lib/use-csom-style";
import { cn } from "@/lib/utils";
import { StackedCard } from "./stacked-card";
@@ -42,6 +43,7 @@ export function StackedCardsContainer({
? survey.styling?.cardBorderColor?.light
: styling.cardBorderColor?.light;
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const spacerRef = useRef<HTMLDivElement>(null);
const resizeObserver = useRef<ResizeObserver | null>(null);
const [cardHeight, setCardHeight] = useState("auto");
const [cardWidth, setCardWidth] = useState<number>(0);
@@ -134,6 +136,13 @@ export function StackedCardsContainer({
};
}, [blockIdxTemp, cardArrangement, cardRefs]);
// Keep spacer height in sync via CSSOM (CSP compliance)
useEffect(() => {
if (spacerRef.current) {
spacerRef.current.style.setProperty("height", cardHeight);
}
}, [cardHeight]);
// Reset block progress, when card arrangement changes
useEffect(() => {
if (shouldResetBlockId) {
@@ -152,13 +161,15 @@ export function StackedCardsContainer({
onMouseLeave={() => {
setHovered(false);
}}>
<div style={{ height: cardHeight }} />
<div ref={spacerRef} />
{cardArrangement === "simple" ? (
<div
id={`questionCard-${blockIdxTemp.toString()}`}
data-testid={`questionCard-${blockIdxTemp.toString()}`}
className={cn("bg-survey-bg w-full overflow-hidden", fullSizeCards ? "h-full" : "")}
style={borderStyles}>
ref={(el) => {
if (el) applyCsomStyles(el, borderStyles);
}}>
{getCardContent(blockIdxTemp, 0)}
</div>
) : (

View File

@@ -70,7 +70,7 @@ export function SurveyContainer({
if (!isModal) {
return (
<div id="fbjs" className="formbricks-form" style={{ height: "100%", width: "100%" }} dir={dir}>
<div id="fbjs" className="formbricks-form fb-full-size" dir={dir}>
{children}
</div>
);

View File

@@ -0,0 +1,42 @@
import { useCallback } from "preact/hooks";
/**
* Returns a ref callback that applies style properties via CSSOM (element.style.setProperty)
* instead of inline `style` attributes. This bypasses CSP `style-src` restrictions because
* the browser treats CSSOM mutations as script-driven DOM manipulation, not inline styles.
*
* Usage:
* const csomRef = useCsomStyle({ zIndex: "1000", transform: "translateY(25%)" });
* <div ref={csomRef} />
*/
export function useCsomStyle(
styles: Record<string, string | number | undefined | null>
): (el: HTMLElement | null) => void {
return useCallback(
(el: HTMLElement | null) => {
if (!el) return;
for (const [prop, value] of Object.entries(styles)) {
if (value === undefined || value === null) continue;
// Convert camelCase to kebab-case (e.g. zIndex → z-index, pointerEvents → pointer-events)
const kebab = prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
el.style.setProperty(kebab, String(value));
}
},
[JSON.stringify(styles)]
);
}
/**
* Applies styles via CSSOM imperatively (for use in ref callbacks that also need
* to store the element reference, e.g. cardRefs).
*/
export function applyCsomStyles(
el: HTMLElement,
styles: Record<string, string | number | undefined | null>
): void {
for (const [prop, value] of Object.entries(styles)) {
if (value === undefined || value === null) continue;
const kebab = prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
el.style.setProperty(kebab, String(value));
}
}

View File

@@ -173,4 +173,37 @@
#fbjs .grecaptcha-badge {
visibility: hidden;
}
/* Submit button base styles using CSS variables (CSP-safe, no inline style needed) */
#fbjs .button-custom {
border-radius: var(--fb-button-border-radius, var(--fb-border-radius));
background-color: var(--fb-button-bg-color, var(--fb-brand-color));
color: var(--fb-button-text-color, var(--fb-brand-text-color));
height: var(--fb-button-height, auto);
font-size: var(--fb-button-font-size, inherit);
font-weight: var(--fb-button-font-weight, inherit);
padding-left: var(--fb-button-padding-x, 1.5rem);
padding-right: var(--fb-button-padding-x, 1.5rem);
padding-top: var(--fb-button-padding-y, 0.375rem);
padding-bottom: var(--fb-button-padding-y, 0.375rem);
}
/* CSP-safe utility classes (avoid inline style attributes) */
#fbjs .fb-full-size {
height: 100%;
width: 100%;
}
#fbjs .fb-translate-x-center {
transform: translateX(-50%);
}
#fbjs .fb-content-transition {
transition: opacity 300ms ease-in-out;
height: 100%;
}
#fbjs .fb-card-transition {
transition: transform 600ms ease-in-out, opacity 600ms ease-in-out, width 600ms ease-in-out;
}