SkillAudit Research · CSS Injection · Alignment Attacks · Consent Displacement

CSS Alignment Attack Synthesis: How align-items, align-self, justify-items, and justify-self Form a Complete Per-Item Displacement Framework

Four CSS alignment properties — two container-level, two item-level, spanning two layout axes — can displace MCP consent disclosures without touching display, visibility, or opacity. Understanding them as a unified 2×2 matrix reveals why checking any single property is insufficient: a hostile MCP stylesheet only needs one cell of the matrix to hide consent from the user.

August 6, 2026 · SkillAudit Research · ~1,800 words

Contents

  1. The 2×2 alignment matrix
  2. Container-scope attacks: align-items and justify-items
  3. Item-scope attacks: align-self and justify-self
  4. Grid-only scope: why justify-* creates a blind spot
  5. Physical keyword evasion
  6. Deferred JS-triggered displacement
  7. Unified alignment attack detector
  8. Safe consent layout patterns

The 2×2 Alignment Matrix

CSS Box Alignment Module Level 3 defines four properties that control where items sit within a flex or grid container. They span two dimensions:

Cross axis (block / perpendicular)
Inline axis (main / row)
Container default
align-items — sets cross-axis alignment for all children; flex + grid
justify-items — sets inline-axis alignment for all children; grid only
Per-item override
align-self — overrides align-items for one specific child; flex + grid
justify-self — overrides justify-items for one specific child; grid only

An audit that checks only one of these four cells leaves the other three as unchecked displacement vectors. A hostile MCP stylesheet chooses whichever cell the auditor doesn't check.

Container-Scope Attacks: align-items and justify-items

align-items sets the cross-axis alignment default for every child of a flex or grid container. An MCP server applies align-items: flex-end to the install container — positioning every child (title, input, button, consent) at the cross-axis end. When the container is constrained (fixed height in a row-flex, or fixed width in a column-flex) and has overflow: hidden, end-alignment moves consent to the edge that gets clipped. The install form items, which are not near the edge, remain visible:

/* align-items hostile default — SA-CSS-ALITM-001 */
.mcp-install-row {
  display: flex;
  flex-direction: row;
  align-items: flex-end;  /* cross axis = vertical; all items pushed to bottom */
  height: 50px;
  overflow: hidden;
}
/* Install form elements are short (~30px) → visible inside 50px container.
   Consent paragraph is taller (60px) → flex-end places it at y=50, extending to y=110. Clipped. */

justify-items works the same way on the inline axis, but only in CSS Grid (it has no effect in Flexbox). A grid container with justify-items: end positions all cells at the inline-end of their column. Pair this with a zero-width or narrow column and overflow: hidden, and consent content overflows from the column end:

/* justify-items hostile default — SA-CSS-JUITM-001 */
.mcp-install-grid {
  display: grid;
  grid-template-columns: 300px 0;  /* second column = zero width */
  justify-items: end;              /* all items at inline-end of their column */
}
/* Install form items in column 1 (300px) — end-aligned at x=300, still visible.
   Consent in column 2 (0px) — end-aligned at x=300+0=300, overflows from x=300 rightward. Clipped. */

The detection requirement for container-scope properties is O(containers): one check per flex or grid container is sufficient to flag the hostile container default. An audit that scans every flex/grid element for hostile align-items or justify-items values covers all potential container-scope attacks in a single pass.

Item-Scope Attacks: align-self and justify-self

Item-scope properties are the more surgically precise attack vector. align-self overrides the container's align-items for exactly one flex or grid child. A hostile MCP stylesheet can set a benign container default — align-items: flex-start, perfectly safe — while setting align-self: flex-end on only the consent element. The install form items inherit the safe container default and remain visible; only consent is displaced:

/* align-self surgical targeting — SA-CSS-ALSLF-001 */
.mcp-install-column {
  display: flex;
  flex-direction: column;
  align-items: flex-start;  /* container default: safe, all items left-aligned */
  width: 300px;
  overflow: hidden;
}
.mcp-install-title  { /* align-self: auto → flex-start: left, visible */ }
.mcp-install-input  { /* align-self: auto → flex-start: left, visible */ }
.mcp-install-button { /* align-self: auto → flex-start: left, visible */ }

.mcp-consent-disclosure {
  align-self: flex-end;  /* per-item override: cross-axis end = right edge */
  width: 0;              /* zero width at right edge: content overflows right, clipped */
}

justify-self does the same on the inline axis, in grid only. The attack pattern is identical: a safe justify-items container default, with justify-self: end on only the consent element. Because justify-self is a grid-only property, it can appear in an audit without being checked if the audit tool only examines flex contexts.

The detection requirement for item-scope properties is O(elements): the audit must scan every child of every flex or grid container. A container-only check will miss per-item displacement entirely. The attack surface is proportional to the total DOM size, not the number of containers.

Why container-scope detection is insufficient: An audit that checks container properties (align-items, justify-items, justify-content) will always miss align-self and justify-self attacks. A hostile stylesheet passes the container check with flying colors (align-items: flex-start is perfectly safe) while the consent-specific per-item override hides in the child's computed style.

Grid-Only Scope: Why justify-* Creates a Blind Spot

The inline-axis column (justify-items and justify-self) applies only in CSS Grid. In a flex container, both properties are ignored — they have no computed effect. This asymmetry creates a systematic blind spot in audit tools designed primarily for flexbox:

Property Flex container? Grid container? Audit coverage risk
align-items Yes — cross axis Yes — block axis Low — widely audited in both contexts
align-self Yes — cross axis Yes — block axis Medium — requires per-element scan, not just container
justify-items No effect Yes — inline axis High — flex-focused auditors skip grid inline-axis checks
justify-self No effect Yes — inline axis Critical — grid-only + per-element scan = double blind spot

A hostile MCP server that wraps its install UI in a CSS Grid container (trivially easy — display: grid requires one rule) gains access to the two highest-risk cells of the matrix: justify-items and justify-self, which many audit tools treat as irrelevant because their flex test cases don't exercise them.

Physical Keyword Evasion

CSS Box Alignment defines two groups of keyword values: logical (flow-relative: start, end, flex-start, flex-end, self-start, self-end) and physical (absolute: left, right). The physical keywords bypass the logical coordinate system: right always means the physical right edge, regardless of writing-mode, direction, or flex/grid axis orientation.

This matters for justify-self specifically: an audit that checks for justify-self: end will miss justify-self: right. Both displace consent to the right side of a grid column cell, but only end matches a logical-keyword-focused pattern:

/* Physical keyword evasion — SA-CSS-JUSLF-003 */
.mcp-consent-disclosure {
  grid-column: 2 / 2;   /* zero-span column */
  justify-self: right;  /* physical "right" — not "end", not "flex-end" */
                        /* auditors checking /flex-end|end|self-end/ miss this */
}

/* Detection: must include physical keywords */
const hostileJustifySelf = /flex-end|end|self-end|right/;
/* Note: "left" can also be hostile in RTL layouts */

The same physical/logical distinction applies to align-self in some layout contexts. Any detection regex that only targets logical keywords will miss physical keyword variants.

Deferred JS-Triggered Displacement

All four alignment properties can be set dynamically via JavaScript — either by toggling a class on the container (affecting align-items / justify-items) or by setting an inline style on the consent element (affecting align-self / justify-self). The deferred pattern is the most evasion-resistant variant: alignment starts at a safe value at page load (consent is visible, passes a load-time audit), then changes after the audit window closes:

/* Deferred container default — SA-CSS-ALITM-004 */
/* Starts: align-items: flex-start (safe, consent visible) */
/* After 2s: align-items: flex-end (consent displaced) */
setTimeout(() => {
  document.querySelector('.mcp-install-row').classList.add('mcp-loaded');
}, 2000);

/* CSS */
.mcp-install-row.mcp-loaded { align-items: flex-end; }

/* Deferred per-item override — SA-CSS-ALSLF-004 */
/* Starts: align-self: auto (safe) */
/* On button hover: align-self: flex-end (consent displaced at moment of click) */
document.querySelector('.mcp-install-button').addEventListener('mouseover', () => {
  document.querySelector('.mcp-consent-disclosure').style.alignSelf = 'flex-end';
});

Container-class deferred attacks can be detected by watching the container element for class changes via MutationObserver. Per-item inline style deferred attacks require a MutationObserver on each consent element, watching the style attribute specifically. Both observers must be installed at document-ready time to capture mutations that occur during user interaction.

Unified Alignment Attack Detector

A complete alignment attack detector must cover all four cells of the matrix simultaneously, distinguishing container checks (one pass per container) from item checks (one pass per element), and including physical keyword variants:

/* Unified CSS alignment attack detector — covers all four properties */
function detectAlignmentAttacks() {
  const findings = [];

  /* Hostile alignment keyword sets */
  const hostileAlign  = /flex-end|end|self-end|baseline/;   /* cross-axis */
  const hostileJustify = /flex-end|end|self-end|right/;     /* inline-axis — includes physical */

  /* Consent element selector */
  const CONSENT = /consent|disclosure|terms|privacy/i;
  function isConsentEl(el) { return CONSENT.test(el.textContent || '') || CONSENT.test(el.className || ''); }

  for (const el of document.querySelectorAll('*')) {
    const s = getComputedStyle(el);
    const isFlex = s.display === 'flex' || s.display === 'inline-flex';
    const isGrid = s.display === 'grid' || s.display === 'inline-grid';
    if (!isFlex && !isGrid) continue;

    /* Container-scope checks: align-items (flex+grid) and justify-items (grid only) */
    if (hostileAlign.test(s.alignItems)) {
      const consentChildren = [...el.children].filter(isConsentEl);
      if (consentChildren.length > 0) {
        findings.push({ id: 'SA-CSS-ALITM', severity: 'high',
          message: `Container align-items:${s.alignItems} — ${consentChildren.length} consent child(ren) displaced cross-axis.` });
      }
    }
    if (isGrid && hostileJustify.test(s.justifyItems)) {
      const consentChildren = [...el.children].filter(isConsentEl);
      if (consentChildren.length > 0) {
        findings.push({ id: 'SA-CSS-JUITM', severity: 'high',
          message: `Grid justify-items:${s.justifyItems} — ${consentChildren.length} consent child(ren) displaced inline-axis.` });
      }
    }

    /* Item-scope checks: align-self and justify-self on each child */
    for (const child of el.children) {
      if (!isConsentEl(child)) continue;
      const cs = getComputedStyle(child);
      const rect = child.getBoundingClientRect();
      const isHidden = rect.width < 2 || rect.height < 2
        || rect.top > window.innerHeight || rect.bottom < 0
        || rect.left > window.innerWidth || rect.right < 0;

      if (hostileAlign.test(cs.alignSelf)) {
        findings.push({ id: 'SA-CSS-ALSLF', severity: isHidden ? 'critical' : 'high',
          message: `Consent child align-self:${cs.alignSelf} — per-item cross-axis displacement. Hidden: ${isHidden}.` });
      }
      if (isGrid && hostileJustify.test(cs.justifySelf)) {
        findings.push({ id: 'SA-CSS-JUSLF', severity: isHidden ? 'critical' : 'high',
          message: `Consent child justify-self:${cs.justifySelf} — per-item inline-axis displacement. Hidden: ${isHidden}.` });
      }
    }
  }
  return findings;
}

/* MutationObserver for deferred per-item changes */
function watchAlignmentMutations() {
  const findings = [];
  const observer = new MutationObserver((mutations) => {
    for (const mut of mutations) {
      if (mut.type !== 'attributes') continue;
      const el = mut.target;
      const cs = getComputedStyle(el);
      if (/flex-end|end|self-end/.test(cs.alignSelf) || /flex-end|end|self-end|right/.test(cs.justifySelf)) {
        findings.push({ id: 'SA-CSS-DEFERRED', severity: 'critical',
          message: `Consent element inline style changed: alignSelf=${cs.alignSelf}, justifySelf=${cs.justifySelf}. Deferred displacement on user interaction.` });
      }
    }
  });
  document.querySelectorAll('[class*="consent"],[class*="disclosure"],[class*="terms"]').forEach(el =>
    observer.observe(el, { attributes: true, attributeFilter: ['style', 'class'] })
  );
  return { observer, findings };
}

Detection complexity is asymmetric: container-scope properties require O(containers) checks; item-scope properties require O(DOM elements) checks. The difference matters at scale — a UI with 50 flex/grid containers may have 500 child elements. An audit tool that only performs container-level checks runs 10× faster but misses the entire per-item attack surface.

Safe Consent Layout Patterns

The simplest safe pattern is to avoid flex and grid containers entirely for consent disclosures — use normal block flow, which has no alignment axes that can be exploited:

/* Safe: block-flow consent, no flex/grid context */
.mcp-consent-disclosure {
  display: block;      /* normal flow — no align-self, justify-self, align-items */
  width: 100%;
  visibility: visible;
  overflow: visible;
  /* No parent flex/grid container should set align-items or justify-items */
}

When flex or grid is required for layout reasons, use explicit safe alignment values and audit for deviation:

/* Safe: explicit flex with auditable alignment */
.mcp-install-container {
  display: flex;
  flex-direction: column;
  align-items: flex-start;  /* or: stretch — both safe for consent visibility */
}
.mcp-consent-disclosure {
  align-self: auto;   /* inherits container align-items — no per-item override */
  /* Do NOT set justify-self in grid contexts unless explicitly audited */
  min-height: 1lh;    /* ensure minimum content height cannot collapse to zero */
}

Any deviation from align-self: auto on a consent element should be treated as a finding requiring justification. The same applies to any container that sets align-items or justify-items to a non-default end-alignment value while also having overflow: hidden.

Synthesis

The four CSS alignment properties are not four separate attack techniques — they are four cells in a single 2×2 matrix organized by axis and scope. An auditor who understands this structure knows exactly how many independent displacement vectors need checking: not just "does this element have a visibility property" but "which cell of the matrix is active, and at which scope".

The asymmetric detection cost (O(containers) for container properties vs O(DOM elements) for item properties) means that cutting corners on item-scope checking is the most common audit gap. That gap is precisely what SA-CSS-ALSLF and SA-CSS-JUSLF findings target in SkillAudit reports.

Run a free SkillAudit scan on your MCP server or Claude skill at skillaudit.dev — the scanner checks all four alignment matrix cells including per-item align-self and justify-self with both logical and physical keyword patterns.