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');
        }
      }
    }
  }
}
AttackSeverityModern browser exploitable?Detection method
UTF-7 encoding confusion — single-quote injection bypassHIGHMostly no (HTTP charset overrides)Flag @charset "UTF-7"; scan for +ACc-/+ADs-/+AHs- sequences in stylesheet bytes
Windows-1252 high-byte range bypassHIGHContext-dependentFlag @charset "windows-1252"; scan 0x80–0x9F byte range for CSS token values
Shift-JIS backslash absorptionMEDIUMMostly no (UTF-8 standard)Flag @charset "Shift_JIS"; check for 2-byte sequences with 0x5C second byte
@charset mid-stylesheet injection (invalid placement)MEDIUMContext-dependent (lenient parsers)Flag @charset not at rule index 0; flag @charset after other CSS rules in file