mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 17:35:05 +10:00
feat: add custom branding for signing pages (#2785)
Platform-plan organisations and teams can now customise non-embed signing pages with six brand colour tokens, a border-radius, and a free-text custom CSS block (up to 256 KB). - Stored on OrganisationGlobalSettings / TeamGlobalSettings; teams inherit from the org via brandingEnabled === null. - CSS is sanitised on save (PostCSS) so we can inline it at SSR with no per-render parsing. - Rendered via a nonce'd <style> scoped under .documenso-branded, using native CSS nesting so user selectors don't need scoping. - Gated on the existing embedSigningWhiteLabel claim (or self-hosted) — reuses the embed white-label decision.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import type { TCssVarsSchema } from '../types/css-vars';
|
||||
|
||||
/**
|
||||
* Normalise a branding-colours payload coming from a settings form.
|
||||
*
|
||||
* The colour-pickers store empty strings for cleared fields, and
|
||||
* `ZCssVarsSchema.default({})` produces `{}` when the form is submitted
|
||||
* without any colour overrides. Persisting either as a non-null value would
|
||||
* silently mask the org's defaults for a team, and produce noisy "this is
|
||||
* an override of nothing" rows in the database.
|
||||
*
|
||||
* This helper:
|
||||
* - strips keys whose value is `undefined`, `null`, or an empty string
|
||||
* - returns `null` if the result has no remaining keys
|
||||
* - leaves all other keys verbatim (validation against ZCssVarsSchema is
|
||||
* expected to have happened at the request boundary)
|
||||
*
|
||||
* `undefined` input means "no change" — the caller should not pass it
|
||||
* through to Prisma. We pass it through unchanged so handlers can keep their
|
||||
* existing `=== undefined` branches.
|
||||
*/
|
||||
export const normalizeBrandingColors = (
|
||||
input: TCssVarsSchema | null | undefined,
|
||||
): TCssVarsSchema | null | undefined => {
|
||||
if (input === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (input === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleaned: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(cleaned).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cleaned as TCssVarsSchema;
|
||||
};
|
||||
@@ -124,6 +124,8 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
|
||||
brandingLogo: '',
|
||||
brandingUrl: '',
|
||||
brandingCompanyDetails: '',
|
||||
brandingColors: null,
|
||||
brandingCss: '',
|
||||
|
||||
emailId: null,
|
||||
emailReplyTo: null,
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { sanitizeBrandingCss } from './sanitize-branding-css';
|
||||
|
||||
const normalize = (css: string) => css.replace(/\s+/g, ' ').trim();
|
||||
|
||||
/**
|
||||
* The sanitiser does NOT scope selectors. Scoping is applied at render time
|
||||
* by wrapping the entire sanitised output in `.documenso-branded { ... }` via
|
||||
* native CSS nesting (see `RecipientBranding`). These tests assert that
|
||||
* selectors are preserved verbatim and only validated.
|
||||
*/
|
||||
describe('sanitizeBrandingCss', () => {
|
||||
describe('empty input', () => {
|
||||
it('returns empty output for an empty string', () => {
|
||||
const result = sanitizeBrandingCss('');
|
||||
|
||||
expect(result.css).toBe('');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty output for whitespace-only input', () => {
|
||||
const result = sanitizeBrandingCss(' \n\t \n');
|
||||
|
||||
expect(result.css).toBe('');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selector preservation', () => {
|
||||
it('preserves a bare class selector', () => {
|
||||
const result = sanitizeBrandingCss('.foo { color: red; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('.foo { color: red; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves a tag selector', () => {
|
||||
const result = sanitizeBrandingCss('h1 { color: red; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('h1 { color: red; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves combinators', () => {
|
||||
const result = sanitizeBrandingCss('.a > .b + .c ~ .d { color: red; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('.a > .b + .c ~ .d { color: red; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves comma-separated selectors', () => {
|
||||
const result = sanitizeBrandingCss('.a, .b { color: red; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('.a, .b { color: red; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves body/html/:root verbatim (will no-op once nested at render)', () => {
|
||||
const result = sanitizeBrandingCss('body { background: black; }');
|
||||
|
||||
// Selector is left as-is. At render time this becomes
|
||||
// `.documenso-branded body { ... }`, which won't match anything since
|
||||
// <body> is an ancestor of the wrapper. Documented tradeoff.
|
||||
expect(normalize(result.css)).toBe('body { background: black; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pseudo-elements', () => {
|
||||
it('drops a rule containing ::before', () => {
|
||||
const result = sanitizeBrandingCss(".foo::before { content: 'x'; }");
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('selector');
|
||||
});
|
||||
|
||||
it('drops a rule containing ::after', () => {
|
||||
const result = sanitizeBrandingCss(".foo::after { content: 'x'; }");
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops a rule containing ::backdrop', () => {
|
||||
const result = sanitizeBrandingCss('.foo::backdrop { color: red; }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops a rule containing ::marker', () => {
|
||||
const result = sanitizeBrandingCss('li::marker { color: red; }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops a rule using legacy single-colon :before', () => {
|
||||
const result = sanitizeBrandingCss(".foo:before { content: 'x'; }");
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('selector');
|
||||
});
|
||||
|
||||
it('drops a rule using legacy single-colon :after', () => {
|
||||
const result = sanitizeBrandingCss(".foo:after { content: 'x'; }");
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps ::placeholder verbatim', () => {
|
||||
const result = sanitizeBrandingCss('input::placeholder { color: gray; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('input::placeholder { color: gray; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps ::selection verbatim', () => {
|
||||
const result = sanitizeBrandingCss('p::selection { background: yellow; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('p::selection { background: yellow; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('universal selector', () => {
|
||||
it('drops a bare * selector rule', () => {
|
||||
const result = sanitizeBrandingCss('* { color: red; }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('selector');
|
||||
});
|
||||
|
||||
it('drops a rule with * combined with descendant', () => {
|
||||
const result = sanitizeBrandingCss('* .x { color: red; }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps attribute selectors that include * inside', () => {
|
||||
const result = sanitizeBrandingCss('[class*="foo"] { color: red; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('[class*="foo"] { color: red; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocked properties', () => {
|
||||
const blockedProperties = [
|
||||
'display',
|
||||
'visibility',
|
||||
'opacity',
|
||||
'pointer-events',
|
||||
'position',
|
||||
'inset',
|
||||
'top',
|
||||
'right',
|
||||
'bottom',
|
||||
'left',
|
||||
'z-index',
|
||||
'transform',
|
||||
'clip',
|
||||
'clip-path',
|
||||
'mask',
|
||||
'mask-image',
|
||||
'content',
|
||||
'width',
|
||||
'height',
|
||||
'min-width',
|
||||
'min-height',
|
||||
'max-width',
|
||||
'max-height',
|
||||
'overflow',
|
||||
'overflow-x',
|
||||
'overflow-y',
|
||||
'font-size',
|
||||
'letter-spacing',
|
||||
'word-spacing',
|
||||
'line-height',
|
||||
'text-indent',
|
||||
];
|
||||
|
||||
for (const prop of blockedProperties) {
|
||||
it(`strips the "${prop}" property`, () => {
|
||||
const result = sanitizeBrandingCss(`.x { ${prop}: 10px; color: red; }`);
|
||||
|
||||
expect(result.css).not.toContain(`${prop}:`);
|
||||
expect(result.css).toContain('color: red');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('property');
|
||||
expect(result.warnings[0].detail).toContain(prop);
|
||||
});
|
||||
}
|
||||
|
||||
it('is case-insensitive on property names', () => {
|
||||
const result = sanitizeBrandingCss('.x { DISPLAY: none; color: red; }');
|
||||
|
||||
expect(result.css).not.toMatch(/display/i);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('property');
|
||||
});
|
||||
|
||||
const allowedProperties: Array<[string, string]> = [
|
||||
['color', 'red'],
|
||||
['background', '#fff'],
|
||||
['border', '1px solid black'],
|
||||
['border-radius', '4px'],
|
||||
['font-family', 'sans-serif'],
|
||||
['font-weight', '600'],
|
||||
];
|
||||
|
||||
for (const [prop, value] of allowedProperties) {
|
||||
it(`keeps the "${prop}" property`, () => {
|
||||
const result = sanitizeBrandingCss(`.x { ${prop}: ${value}; }`);
|
||||
|
||||
expect(result.css).toContain(`${prop}: ${value}`);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('blocked values', () => {
|
||||
it('drops a declaration containing url(', () => {
|
||||
const result = sanitizeBrandingCss('.x { background: url(http://evil); }');
|
||||
|
||||
expect(result.css).not.toContain('url(');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('value');
|
||||
});
|
||||
|
||||
it('drops a declaration containing expression(', () => {
|
||||
const result = sanitizeBrandingCss('.x { background: expression(alert(1)); }');
|
||||
|
||||
expect(result.css).not.toContain('expression(');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('value');
|
||||
});
|
||||
|
||||
it('drops a declaration containing javascript: in a quoted value', () => {
|
||||
// PostCSS would throw on bare `javascript:alert(1)` (looks like a
|
||||
// malformed selector inside a declaration). Use a quoted value to
|
||||
// exercise the substring match cleanly.
|
||||
const result = sanitizeBrandingCss('.x { font-family: "javascript:alert"; }');
|
||||
|
||||
expect(result.css).not.toContain('javascript:');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('!important stripping', () => {
|
||||
it('strips !important from a retained declaration', () => {
|
||||
const result = sanitizeBrandingCss('.x { color: red !important; }');
|
||||
|
||||
expect(result.css).not.toContain('!important');
|
||||
expect(result.css).toContain('color: red');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('at-rules', () => {
|
||||
it('drops @import', () => {
|
||||
const result = sanitizeBrandingCss('@import url("https://evil.example/x.css");');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('at-rule');
|
||||
});
|
||||
|
||||
it('drops @font-face', () => {
|
||||
const result = sanitizeBrandingCss('@font-face { font-family: "X"; src: url("x.woff2"); }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('at-rule');
|
||||
});
|
||||
|
||||
it('drops @keyframes', () => {
|
||||
const result = sanitizeBrandingCss('@keyframes spin { to { transform: rotate(360deg); } }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('at-rule');
|
||||
});
|
||||
|
||||
it('drops @supports', () => {
|
||||
const result = sanitizeBrandingCss('@supports (display: grid) { .x { color: red; } }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('at-rule');
|
||||
});
|
||||
|
||||
it('keeps @media with min-width and preserves inner selectors verbatim', () => {
|
||||
const result = sanitizeBrandingCss('@media (min-width: 600px) { .x { color: red; } }');
|
||||
|
||||
expect(normalize(result.css)).toBe('@media (min-width: 600px) { .x { color: red; } }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps @media with prefers-color-scheme and preserves body inside', () => {
|
||||
const result = sanitizeBrandingCss('@media (prefers-color-scheme: dark) { body { background: black; } }');
|
||||
|
||||
expect(normalize(result.css)).toBe('@media (prefers-color-scheme: dark) { body { background: black; } }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips blocked properties inside @media', () => {
|
||||
const result = sanitizeBrandingCss('@media (min-width: 600px) { .x { display: none; color: red; } }');
|
||||
|
||||
expect(result.css).not.toContain('display');
|
||||
expect(result.css).toContain('color: red');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('property');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined input', () => {
|
||||
it('keeps valid rules verbatim and reports each drop', () => {
|
||||
const input = `
|
||||
.ok { color: red !important; }
|
||||
.bad-prop { display: none; background: blue; }
|
||||
.bad-pseudo::before { content: 'x'; }
|
||||
* { color: red; }
|
||||
@import "evil.css";
|
||||
body { background: black; }
|
||||
@media (min-width: 600px) {
|
||||
.responsive { color: green; }
|
||||
}
|
||||
`;
|
||||
|
||||
const result = sanitizeBrandingCss(input);
|
||||
|
||||
// Valid bits present, unchanged.
|
||||
expect(result.css).toContain('.ok');
|
||||
expect(result.css).toContain('color: red');
|
||||
expect(result.css).toContain('.bad-prop');
|
||||
expect(result.css).toContain('background: blue');
|
||||
expect(result.css).toContain('body { background: black');
|
||||
expect(result.css).toContain('@media (min-width: 600px)');
|
||||
expect(result.css).toContain('.responsive');
|
||||
|
||||
// Invalid bits gone.
|
||||
expect(result.css).not.toContain('!important');
|
||||
expect(result.css).not.toContain('display');
|
||||
expect(result.css).not.toContain('::before');
|
||||
expect(result.css).not.toContain('@import');
|
||||
|
||||
// Warning kinds.
|
||||
const kinds = result.warnings.map((w) => w.kind).sort();
|
||||
expect(kinds).toEqual(['at-rule', 'property', 'selector', 'selector'].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('style-close-tag defence', () => {
|
||||
// The sanitised output is inlined into a `<style>` element via SSR. The
|
||||
// browser's HTML parser terminates the element on a literal `</style`
|
||||
// anywhere in the content. PostCSS's serializer normally escapes `<` to
|
||||
// `\3c` whenever it would form `</...`, so the literal sequence should
|
||||
// never reach the output for any of these inputs. These tests pin that
|
||||
// invariant.
|
||||
|
||||
it('escapes </style> inside a string value', () => {
|
||||
const result = sanitizeBrandingCss('.x { font-family: "</style><img src=x onerror=alert(1)>"; }');
|
||||
|
||||
expect(result.css.toLowerCase()).not.toContain('</style');
|
||||
// Whatever else happens, the canonical close-tag bytes must not appear.
|
||||
});
|
||||
|
||||
it('escapes </style> inside a CSS comment', () => {
|
||||
const result = sanitizeBrandingCss('.x { color: red; /* </style><script>alert(1)</script> */ }');
|
||||
|
||||
expect(result.css.toLowerCase()).not.toContain('</style');
|
||||
});
|
||||
|
||||
it('escapes </style> inside an at-rule params block', () => {
|
||||
const result = sanitizeBrandingCss(
|
||||
'@media screen and (foo: bar)</style><script>x()</script> { .x { color: red; } }',
|
||||
);
|
||||
|
||||
expect(result.css.toLowerCase()).not.toContain('</style');
|
||||
});
|
||||
|
||||
it('escapes mixed-case </StYlE> in a value', () => {
|
||||
const result = sanitizeBrandingCss('.x { font-family: "</StYlE>foo"; }');
|
||||
|
||||
expect(result.css.toLowerCase()).not.toContain('</style');
|
||||
});
|
||||
|
||||
it('escapes </style> in an attribute selector value', () => {
|
||||
const result = sanitizeBrandingCss('[data-x="</style><script>alert(1)</script>"] { color: red; }');
|
||||
|
||||
expect(result.css.toLowerCase()).not.toContain('</style');
|
||||
});
|
||||
|
||||
it('preserves benign < not followed by /', () => {
|
||||
// `<script>` (no slash) is not a tag close; PostCSS leaves it as text
|
||||
// and the HTML parser treats it as text inside <style> rawtext mode.
|
||||
const result = sanitizeBrandingCss('.x { font-family: "<script>alert(1)</script>"; }');
|
||||
|
||||
// The output keeps the literal `<script>` (harmless) but escapes the
|
||||
// `</script>` end tag's `<` for the same reason it'd escape `</style>`.
|
||||
expect(result.css).toContain('<script>');
|
||||
expect(result.css.toLowerCase()).not.toContain('</style');
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed CSS', () => {
|
||||
// PostCSS is forgiving; an empty value parses without throwing.
|
||||
it('handles a declaration with an empty value gracefully', () => {
|
||||
const result = sanitizeBrandingCss('.x { color: }');
|
||||
|
||||
expect(result.warnings.filter((w) => w.kind === 'parse-error')).toEqual([]);
|
||||
expect(result.css).toContain('.x');
|
||||
});
|
||||
|
||||
it('reports a parse-error for clearly broken CSS', () => {
|
||||
// Unclosed brace.
|
||||
const result = sanitizeBrandingCss('.x { color: red');
|
||||
|
||||
// PostCSS may or may not throw on this; if it does, we get a
|
||||
// parse-error warning. If it tolerates it, the rule is sanitized.
|
||||
if (result.css === '') {
|
||||
expect(result.warnings.some((w) => w.kind === 'parse-error')).toBe(true);
|
||||
} else {
|
||||
expect(result.css).toContain('.x');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import type { AtRule, Container, Declaration, Rule } from 'postcss';
|
||||
import postcss from 'postcss';
|
||||
import selectorParser from 'postcss-selector-parser';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZSanitizeBrandingCssWarningSchema = z.object({
|
||||
kind: z.enum(['selector', 'property', 'value', 'at-rule', 'parse-error']),
|
||||
detail: z.string(),
|
||||
line: z.number().optional(),
|
||||
});
|
||||
|
||||
export type SanitizeBrandingCssWarning = z.infer<typeof ZSanitizeBrandingCssWarningSchema>;
|
||||
|
||||
export type SanitizeBrandingCssResult = {
|
||||
css: string;
|
||||
warnings: SanitizeBrandingCssWarning[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The class name the sanitised CSS will be wrapped in at render time using
|
||||
* CSS nesting (`.documenso-branded { <user css> }`). The sanitiser itself
|
||||
* does NOT prefix selectors — the wrapper is applied by `RecipientBranding`
|
||||
* on every render so we keep the user's original CSS intact in the database.
|
||||
*/
|
||||
export const SANITIZE_BRANDING_SCOPE_CLASS = 'documenso-branded';
|
||||
|
||||
const BLOCKED_PROPERTIES = new Set([
|
||||
'display',
|
||||
'visibility',
|
||||
'opacity',
|
||||
'pointer-events',
|
||||
'position',
|
||||
'inset',
|
||||
'top',
|
||||
'right',
|
||||
'bottom',
|
||||
'left',
|
||||
'z-index',
|
||||
'transform',
|
||||
'clip',
|
||||
'clip-path',
|
||||
'mask',
|
||||
'mask-image',
|
||||
'content',
|
||||
'width',
|
||||
'height',
|
||||
'min-width',
|
||||
'min-height',
|
||||
'max-width',
|
||||
'max-height',
|
||||
'overflow',
|
||||
'overflow-x',
|
||||
'overflow-y',
|
||||
'font-size',
|
||||
'letter-spacing',
|
||||
'word-spacing',
|
||||
'line-height',
|
||||
'text-indent',
|
||||
]);
|
||||
|
||||
const BLOCKED_VALUE_SUBSTRINGS = ['url(', 'expression(', '@import', 'javascript:'];
|
||||
|
||||
const BLOCKED_PSEUDO_ELEMENTS = new Set([
|
||||
'::before',
|
||||
'::after',
|
||||
'::backdrop',
|
||||
'::marker',
|
||||
// Single-colon legacy forms.
|
||||
':before',
|
||||
':after',
|
||||
]);
|
||||
|
||||
const BLOCKED_AT_RULES = new Set([
|
||||
'import',
|
||||
'font-face',
|
||||
'keyframes',
|
||||
'charset',
|
||||
'namespace',
|
||||
'supports',
|
||||
'page',
|
||||
'document',
|
||||
'viewport',
|
||||
]);
|
||||
|
||||
type SelectorValidationResult = { kind: 'ok' } | { kind: 'drop'; reason: string };
|
||||
|
||||
/**
|
||||
* Validate a selector for the rules we care about, but DO NOT rewrite it.
|
||||
* The sanitised output is later wrapped in `.documenso-branded { ... }` via
|
||||
* native CSS nesting by `RecipientBranding`, so scoping happens at render.
|
||||
*/
|
||||
const validateSelector = (rawSelector: string): SelectorValidationResult => {
|
||||
let dropReason: string | null = null;
|
||||
|
||||
const transform = selectorParser((selectors) => {
|
||||
selectors.each((selector) => {
|
||||
selector.walk((node) => {
|
||||
// Pseudo-element check (works at any depth — even nested pseudos like
|
||||
// `:is(::before)` should be rejected).
|
||||
if (node.type === 'pseudo') {
|
||||
const value = node.value;
|
||||
|
||||
if (BLOCKED_PSEUDO_ELEMENTS.has(value)) {
|
||||
dropReason = `pseudo-element "${value}" not allowed`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (dropReason !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Universal selector check — only when it is a direct child of the
|
||||
// top-level compound (i.e. `* { ... }` or `* .foo { ... }`).
|
||||
// Universal nodes nested inside attribute selectors (`[class*="x"]`)
|
||||
// are a different node type and won't appear here.
|
||||
selector.each((node) => {
|
||||
if (node.type === 'universal') {
|
||||
dropReason = 'universal "*" selector not allowed';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
// We don't keep the result — we only care about parsing and walking
|
||||
// to populate dropReason.
|
||||
transform.processSync(rawSelector);
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: 'drop',
|
||||
reason: error instanceof Error ? error.message : 'failed to parse selector',
|
||||
};
|
||||
}
|
||||
|
||||
if (dropReason !== null) {
|
||||
return { kind: 'drop', reason: dropReason };
|
||||
}
|
||||
|
||||
return { kind: 'ok' };
|
||||
};
|
||||
|
||||
const valueIsBlocked = (rawValue: string): boolean => {
|
||||
const lowered = rawValue.toLowerCase();
|
||||
|
||||
return BLOCKED_VALUE_SUBSTRINGS.some((needle) => lowered.includes(needle));
|
||||
};
|
||||
|
||||
const sanitizeDeclarations = (container: Container, warnings: SanitizeBrandingCssWarning[]): void => {
|
||||
const toRemove: Declaration[] = [];
|
||||
|
||||
container.each((node) => {
|
||||
if (node.type !== 'decl') {
|
||||
return;
|
||||
}
|
||||
|
||||
const decl = node;
|
||||
const propLower = decl.prop.toLowerCase();
|
||||
|
||||
if (BLOCKED_PROPERTIES.has(propLower)) {
|
||||
warnings.push({
|
||||
kind: 'property',
|
||||
detail: `property "${decl.prop}" is not allowed`,
|
||||
line: decl.source?.start?.line,
|
||||
});
|
||||
toRemove.push(decl);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (valueIsBlocked(decl.value)) {
|
||||
warnings.push({
|
||||
kind: 'value',
|
||||
detail: `value of "${decl.prop}" contains a disallowed token`,
|
||||
line: decl.source?.start?.line,
|
||||
});
|
||||
toRemove.push(decl);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (decl.important) {
|
||||
decl.important = false;
|
||||
}
|
||||
});
|
||||
|
||||
for (const decl of toRemove) {
|
||||
decl.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const sanitizeRule = (rule: Rule, warnings: SanitizeBrandingCssWarning[]): void => {
|
||||
const line = rule.source?.start?.line;
|
||||
const validation = validateSelector(rule.selector);
|
||||
|
||||
if (validation.kind === 'drop') {
|
||||
warnings.push({ kind: 'selector', detail: validation.reason, line });
|
||||
rule.remove();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Selector is left as-is. Scoping is applied at render time by wrapping
|
||||
// the entire sanitised CSS in `.documenso-branded { ... }` (CSS nesting).
|
||||
|
||||
sanitizeDeclarations(rule, warnings);
|
||||
|
||||
// If the rule has no declarations left, leave the empty rule in place — the
|
||||
// output is still valid CSS and the user can see what happened. (Removing
|
||||
// it would also be acceptable; we keep it to make warnings easier to map.)
|
||||
};
|
||||
|
||||
const sanitizeAtRule = (atRule: AtRule, warnings: SanitizeBrandingCssWarning[]): void => {
|
||||
const name = atRule.name.toLowerCase();
|
||||
const line = atRule.source?.start?.line;
|
||||
|
||||
if (BLOCKED_AT_RULES.has(name)) {
|
||||
warnings.push({
|
||||
kind: 'at-rule',
|
||||
detail: `at-rule "@${atRule.name}" is not allowed`,
|
||||
line,
|
||||
});
|
||||
atRule.remove();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (name !== 'media') {
|
||||
warnings.push({
|
||||
kind: 'at-rule',
|
||||
detail: `at-rule "@${atRule.name}" is not allowed`,
|
||||
line,
|
||||
});
|
||||
atRule.remove();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Recurse into @media children.
|
||||
const children = atRule.nodes ? [...atRule.nodes] : [];
|
||||
|
||||
for (const child of children) {
|
||||
if (child.type === 'rule') {
|
||||
sanitizeRule(child, warnings);
|
||||
} else if (child.type === 'atrule') {
|
||||
sanitizeAtRule(child, warnings);
|
||||
}
|
||||
// Comments and stray declarations inside @media are left alone /
|
||||
// declarations directly under @media are invalid CSS anyway.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Defence in depth against `<style>` element breakout.
|
||||
*
|
||||
* The sanitised CSS is inlined into a `<style>` element via SSR
|
||||
* `dangerouslySetInnerHTML`. The browser's HTML parser (in RAWTEXT mode for
|
||||
* `<style>` content) terminates the element on a literal `</style` —
|
||||
* regardless of whether it appears inside a CSS string, comment, or at-rule
|
||||
* params. PostCSS's serializer escapes `<` to `\3c` whenever it would form
|
||||
* `</...`, which means a normal round-trip is already safe.
|
||||
*
|
||||
* That escape is implicit in PostCSS, not enforced by our own logic. If a
|
||||
* future PostCSS version, plugin, or alternative serializer regresses, we
|
||||
* still want the output to be safe to inline. This regex is the explicit
|
||||
* tripwire — case-insensitive `</style` anywhere in the final output is a
|
||||
* hard fail.
|
||||
*/
|
||||
const STYLE_CLOSE_TAG_REGEX = /<\s*\/\s*style/i;
|
||||
|
||||
export const sanitizeBrandingCss = (input: string): SanitizeBrandingCssResult => {
|
||||
const warnings: SanitizeBrandingCssWarning[] = [];
|
||||
|
||||
if (input.trim() === '') {
|
||||
return { css: '', warnings };
|
||||
}
|
||||
|
||||
let root;
|
||||
|
||||
try {
|
||||
root = postcss.parse(input);
|
||||
} catch (error) {
|
||||
return {
|
||||
css: '',
|
||||
warnings: [
|
||||
{
|
||||
kind: 'parse-error',
|
||||
detail: error instanceof Error ? error.message : 'failed to parse CSS',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Iterate over a snapshot of top-level children so removal during the loop
|
||||
// is safe.
|
||||
const topLevelChildren = root.nodes ? [...root.nodes] : [];
|
||||
|
||||
for (const node of topLevelChildren) {
|
||||
if (node.type === 'rule') {
|
||||
sanitizeRule(node, warnings);
|
||||
} else if (node.type === 'atrule') {
|
||||
sanitizeAtRule(node, warnings);
|
||||
}
|
||||
// Top-level decls / comments are left as-is.
|
||||
}
|
||||
|
||||
const output = root.toString();
|
||||
|
||||
if (STYLE_CLOSE_TAG_REGEX.test(output)) {
|
||||
return {
|
||||
css: '',
|
||||
warnings: [
|
||||
...warnings,
|
||||
{
|
||||
kind: 'parse-error',
|
||||
detail: 'output contained a literal </style sequence and was rejected',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return { css: output, warnings };
|
||||
};
|
||||
@@ -191,6 +191,8 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
||||
brandingLogo: null,
|
||||
brandingUrl: null,
|
||||
brandingCompanyDetails: null,
|
||||
brandingColors: null,
|
||||
brandingCss: null,
|
||||
|
||||
emailDocumentSettings: null,
|
||||
emailId: null,
|
||||
|
||||
Reference in New Issue
Block a user