Security Guide
MCP server CSS @charset security — encoding declaration injection and confusion attacks
The CSS @charset rule declares the character encoding of a stylesheet. It must be the very first rule in a CSS file, before any other content. When the declared encoding differs from the HTTP Content-Type header, browsers must resolve the conflict — and historical behavior created encoding confusion windows. CSS injection payloads encoded in non-UTF-8 encodings pass UTF-8 sanitizers as harmless byte sequences but decode to attack tokens when the browser parses the stylesheet according to the declared encoding.
How @charset works
The CSS @charset rule has strict placement requirements: it must be the very first thing in a CSS file, appear before any other CSS including whitespace, and be used only once. Its syntax is @charset "encoding-name"; where the encoding name is a string. The CSS parser uses @charset as a hint for decoding the rest of the stylesheet's bytes into characters. The HTTP Content-Type: text/css; charset=utf-8 header takes precedence over @charset in modern browsers — if the HTTP header specifies an encoding, @charset is ignored. However, when no HTTP charset is specified, or in inline contexts, the @charset declaration influences the byte-to-character mapping of the entire stylesheet.
/* Valid @charset — must be first, before all other content */ @charset "UTF-8"; /* standard, redundant with HTTP header */ @charset "ISO-8859-1"; /* Latin-1 Western European */ @charset "windows-1252"; /* Windows Western European (superset of ISO-8859-1) */ @charset "Shift_JIS"; /* Japanese multi-byte encoding */ @charset "UTF-7"; /* Historical — causes security issues */ /* Encoding resolution priority (modern browsers, per CSS Syntax Level 3): 1. HTTP Content-Type: text/css; charset=... ← HIGHEST priority 2. BOM (byte order mark) at start of file 3. @charset declaration 4. Referring document's encoding (if loaded by @import) 5. Default: UTF-8 ← LOWEST priority Security window: when HTTP header has no charset AND no BOM → @charset declaration is authoritative for byte interpretation */
Sanitizer encoding gap: A CSS injection sanitizer that reads bytes as UTF-8 will not recognize a non-UTF-8 encoded injection payload as containing dangerous tokens. If the browser later parses the stylesheet according to a different encoding declared by @charset, bytes that were safe UTF-8 characters become CSS injection tokens. The sanitizer and the browser interpret the same byte stream differently.
Attack 1 (HIGH): UTF-7 encoding confusion — single-quote injection bypass
UTF-7 is a historical encoding that uses the + character as an escape prefix. In UTF-7, +ACc- encodes a single quote (', U+0027). If a CSS injection sanitizer removes all literal single quotes from user input but processes the bytes as UTF-8, the UTF-7-encoded sequence +ACc- passes through unfiltered — it contains no literal single quotes. When the browser parses the stylesheet with @charset "UTF-7" declared, it reinterprets +ACc- as a single quote, completing the CSS injection. The attack decodes to valid CSS only in UTF-7 context.
/* Attack 1: UTF-7 encoding — single-quote injection bypass */
@charset "UTF-7";
/* In UTF-7 encoding, these byte sequences decode to: */
/* +ACc- → ' (U+0027 single quote) */
/* +ADs- → ; (U+003B semicolon) */
/* +AHs- → { (U+007B left brace) */
/* +AH0- → } (U+007D right brace) */
/* If a user-controlled value is injected into a CSS property value:
Sanitizer receives: background-image: url(+ACc-);+AHs-color:red+AH0-
Sanitizer sees (as UTF-8): "url(+ACc-);+AHs-color:red+AH0-" — no single quotes
Sanitizer: no injection detected → passes
Browser with @charset "UTF-7":
Decodes to: background-image: url(');{color:red}
CSS injection: closes the url() value, opens a new rule block
*/
/* Real-world constraints:
Modern browsers largely ignore @charset for security reasons
The HTTP Content-Type charset overrides @charset
But: inline style parsing, some older browser paths, and CSS in mixed contexts
may still honor @charset in edge cases
Detection: flag any @charset declaration other than UTF-8 / UTF-16
*/
Attack 2 (HIGH): Windows-1252 high-byte injection — 0x80–0x9F range bypass
The byte range 0x80–0x9F is a control character range in ISO-8859-1 but maps to printable characters in Windows-1252 (the extended Latin character set used by older Windows software). In UTF-8, this byte range is the start of multi-byte sequences; a lone byte in this range is an invalid UTF-8 sequence and is typically replaced by the replacement character U+FFFD. A CSS sanitizer operating in UTF-8 mode rejects or replaces bytes in this range as invalid. Under @charset "windows-1252", these same bytes decode to specific printable Windows-1252 characters — some of which are CSS tokens.
/* Attack 2: Windows-1252 high-byte range CSS token bypass */
@charset "windows-1252";
/* Windows-1252 byte mappings for 0x80–0x9F range: */
/* 0x82 → ‚ (U+201A) — but in some CSS parsers treated as quote */
/* 0x84 → „ (U+201E) — low double quotation mark */
/* 0x91 → ' (U+2018) — left single quotation mark */
/* 0x92 → ' (U+2019) — right single quotation mark */
/* 0x93 → " (U+201C) — left double quotation mark */
/* 0x94 → " (U+201D) — right double quotation mark */
/* Attack: inject 0x91 (Windows-1252 left single quote) into a CSS value
UTF-8 sanitizer: 0x91 is invalid UTF-8 → replacement character → safe
Windows-1252 browser: 0x91 → U+2018 (typographic left single quote)
Some CSS parsers accept U+2018 as a string delimiter
→ string context closed → CSS injection possible in string-quoted contexts
More direct: 0x7B and 0x7D (ASCII { and }) pass all sanitizers
But bytes like 0x9B → › (U+203A) may create quirks in some parsers */
Attack 3 (MEDIUM): Shift-JIS multi-byte sequence absorption
Shift-JIS is a Japanese character encoding that uses variable-width characters: some characters are one byte, others are two bytes. The second byte of a two-byte Shift-JIS sequence can be in the range 0x40–0x7E or 0x80–0xFC. Critically, 0x5C (the ASCII backslash \) can be the second byte of a legitimate two-byte Shift-JIS character. This means a backslash in a Shift-JIS-encoded file may be "absorbed" as part of the preceding byte's character, effectively escaping the backslash from the CSS parser's perspective. Backslash is a CSS escape character; its removal from the CSS token stream can affect string parsing and URL values.
/* Attack 3: Shift-JIS backslash absorption */
@charset "Shift_JIS";
/* In Shift-JIS, bytes 0x81–0x9F followed by 0x40–0x7E form two-byte chars */
/* 0x81 0x5C → U+FF3C (FULLWIDTH REVERSE SOLIDUS) in some implementations */
/* BUT: the 0x5C byte (ASCII backslash) is consumed as the second byte */
/* CSS attack scenario:
Normal CSS: .selector { background: url('...\'); }
The \' is a CSS-escaped single quote — not a string terminator
Shift-JIS confusion: if 0xXX (a valid first byte of a 2-byte char)
immediately precedes 0x5C, the backslash is absorbed into the 2-byte char
Result: the backslash is NOT seen as a CSS escape character
The single quote after it terminates the string context
CSS injection follows after the unintended string termination
This is the historical "backslash eating" vulnerability in Shift-JIS
affecting SQL, HTML, and CSS injection contexts equally
*/
Attack 4 (MEDIUM): invalid @charset placement — parser edge case exploitation
The CSS spec mandates that @charset must be the absolute first token in a CSS file — no whitespace, no BOM, no other rules may precede it. A @charset declaration that appears anywhere other than the very start of the file is invalid and must be ignored by conforming parsers. However, non-conforming or lenient parsers (some older browsers, some CSS-in-JS processors, some MCP CSS injection points that process CSS rules sequentially) may honor an out-of-place @charset as a mid-stylesheet encoding switch. This creates a split-context attack where content before the injected @charset is parsed as UTF-8 and content after it may be parsed differently.
/* Attack 4: @charset injected mid-stylesheet — parser split context */
/* First portion of CSS file — parsed as UTF-8 (default) */
.header { color: #333; }
.footer { color: #666; }
/* Injection point: attacker controls a value and injects: */
/* } @charset "UTF-7"; .consent-dialog { */
/* After injection the CSS contains: */
.some-rule { property: injected-value } /* closes previous rule */
@charset "UTF-7"; /* mid-file @charset declaration */
.consent-dialog { /* opens new rule */
/* Subsequent properties may be in UTF-7-decoded context in lenient parsers */
}
/* Conforming browser behavior: ignores @charset not at very start of file
Non-conforming or older parser: may switch encoding at declaration point
CSS-in-JS processor: may process @charset as a rule and change its encoding context
Detection: @charset declarations not at position 0 in the stylesheet bytes */
Detection
/* Detect @charset security issues */
function auditCharsetDeclarations() {
const issues = [];
for (const sheet of document.styleSheets) {
try {
for (let i = 0; i < sheet.cssRules.length; i++) {
const rule = sheet.cssRules[i];
/* CSSCharsetRule: @charset declaration */
if (rule instanceof CSSCharsetRule) {
const encoding = rule.encoding.toLowerCase();
/* Flag non-UTF-8 encodings */
const safeEncodings = ['utf-8', 'utf8'];
const suspicious = ['utf-7', 'windows-1252', 'windows-1251',
'shift_jis', 'shift-jis', 'euc-jp', 'euc-kr', 'gb2312',
'iso-8859-1', 'iso-8859-2', 'koi8-r', 'big5'];
if (!safeEncodings.includes(encoding)) {
issues.push({
type: 'non-utf8-charset',
encoding,
severity: suspicious.includes(encoding) ? 'HIGH' : 'MEDIUM',
position: i
});
}
/* Flag @charset not at position 0 */
if (i !== 0) {
issues.push({
type: 'misplaced-charset',
encoding,
severity: 'MEDIUM',
position: i,
note: '@charset at rule index ' + i + ' (not first rule) — parser edge case'
});
}
}
}
} catch (e) { /* cross-origin */ }
}
/* Also check HTTP-level: does the server send charset in Content-Type? */
/* If yes, @charset is overridden — lower risk but still flag for review */
/* This requires checking the response headers of each stylesheet URL */
return issues;
}
const charsetIssues = auditCharsetDeclarations();
if (charsetIssues.length > 0) {
console.error('CSS @charset security issues:', charsetIssues);
}
/* Additional check: scan raw stylesheet bytes for high-byte sequences
when charset is declared non-UTF-8 (requires raw byte access) */
async function checkRawEncodingIssues(sheetUrl, declaredCharset) {
const response = await fetch(sheetUrl);
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
if (['utf-7'].includes(declaredCharset.toLowerCase())) {
/* UTF-7: look for + sequences that decode to injection tokens */
for (let i = 0; i < bytes.length - 4; i++) {
if (bytes[i] === 0x2B) { /* + prefix in UTF-7 */
console.warn('UTF-7 encoded sequence at byte offset', i, '— potential injection');
}
}
}
if (['shift_jis', 'shift-jis'].includes(declaredCharset.toLowerCase())) {
/* Shift-JIS: look for first-byte of 2-byte sequence followed by 0x5C (backslash) */
for (let i = 0; i < bytes.length - 1; i++) {
if ((bytes[i] >= 0x81 && bytes[i] <= 0x9F) ||
(bytes[i] >= 0xE0 && bytes[i] <= 0xFC)) {
if (bytes[i + 1] === 0x5C) { /* backslash absorption */
console.warn('Shift-JIS backslash absorption at byte', i,
'— CSS escape character absorbed into 2-byte sequence');
}
}
}
}
}
| Attack | Severity | Modern browser exploitable? | Detection method |
|---|---|---|---|
| UTF-7 encoding confusion — single-quote injection bypass | HIGH | Mostly no (HTTP charset overrides) | Flag @charset "UTF-7"; scan for +ACc-/+ADs-/+AHs- sequences in stylesheet bytes |
| Windows-1252 high-byte range bypass | HIGH | Context-dependent | Flag @charset "windows-1252"; scan 0x80–0x9F byte range for CSS token values |
| Shift-JIS backslash absorption | MEDIUM | Mostly no (UTF-8 standard) | Flag @charset "Shift_JIS"; check for 2-byte sequences with 0x5C second byte |
| @charset mid-stylesheet injection (invalid placement) | MEDIUM | Context-dependent (lenient parsers) | Flag @charset not at rule index 0; flag @charset after other CSS rules in file |