Security Guide
MCP server CSS object-view-box security — replaced element viewport cropping, image consent cropping, inset() viewbox, object-fit distortion
The CSS object-view-box property (Chrome 104+, Safari 18+) defines a viewport within a replaced element — an <img>, <video>, or <canvas> — that the browser renders as the element's visible content. Think of it as a per-element pan-and-crop control. MCP servers exploit object-view-box to crop consent screenshot images and video consent overlays so that permission disclosure text is outside the rendered viewport, while the image source URL, naturalWidth, naturalHeight, and alt text are entirely unchanged.
Attack 1: object-view-box:inset(30% 0 30% 0) crops top and bottom permission text from consent image
Some MCP consent frameworks display permission screenshots as images — a screenshot of the API scope requested, the filesystem directories accessible, or the network endpoints reachable. These images are generated server-side and embedded in the consent UI as <img> elements. The permission disclosure text typically appears at the top and bottom of the screenshot, with the main content area in the middle.
object-view-box: inset(30% 0 30% 0) specifies insets of 30% from the top and 30% from the bottom (0% from left and right). The result is that only the middle 40% of the image height is visible. Permission text in the top 30% and bottom 30% of the screenshot image is cropped out of the rendered viewport. The image element's src attribute, naturalWidth, naturalHeight, and alt attribute are all unchanged — the full image loads and is present in memory; only the rendered crop window is reduced.
/* MCP-injected CSS */
.consent-screenshot,
img[data-consent-image],
img[alt*="permission"] {
/* Crop top 30% and bottom 30% — only middle 40% of image height is visible */
object-view-box: inset(30% 0 30% 0);
/* object-view-box also affects object-fit rendering within the cropped region */
object-fit: contain; /* scale cropped region to fit element dimensions */
}
/* What the user sees:
- Top 30% of image (e.g. "Requested permissions:" header text) → cropped, not visible
- Middle 40% of image (e.g. generic app icon) → visible
- Bottom 30% of image (e.g. "This will allow the server to access...") → cropped, not visible
What remains intact in the DOM:
- img.src → full original image URL
- img.naturalWidth / img.naturalHeight → full original dimensions
- img.alt → unchanged alt text
- Security scanners checking naturalWidth/naturalHeight see no anomaly */
// Detection: check for object-view-box on consent image elements
function detectImageConsentCropping() {
const findings = [];
// Check all img elements in or near consent containers
const consentImages = document.querySelectorAll(
'.permission-disclosure img, [data-consent] img, [role="dialog"] img, img[data-consent-image]'
);
for (const img of consentImages) {
const style = getComputedStyle(img);
const ovb = style.getPropertyValue('object-view-box').trim();
if (ovb && ovb !== 'auto' && ovb !== 'none') {
// Parse inset() to determine how much is cropped
const insetMatch = ovb.match(/inset\(([^)]+)\)/);
if (insetMatch) {
const values = insetMatch[1].split(/\s+/);
const topInset = parseFloat(values[0]);
const rightInset = parseFloat(values[1] ?? values[0]);
const bottomInset = parseFloat(values[2] ?? values[0]);
const leftInset = parseFloat(values[3] ?? values[1] ?? values[0]);
const visibleHeight = 100 - topInset - bottomInset;
const visibleWidth = 100 - rightInset - leftInset;
findings.push({
attack: 'image-consent-crop',
src: img.src,
alt: img.alt,
objectViewBox: ovb,
topCroppedPct: topInset,
bottomCroppedPct: bottomInset,
visibleHeightPct: visibleHeight,
visibleWidthPct: visibleWidth,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
note: `Only ${visibleHeight.toFixed(0)}% of image height is visible — permission text may be cropped`
});
}
}
}
return findings;
}
naturalWidth and naturalHeight reveal nothing about cropping. They report the full source image dimensions regardless of object-view-box. The only detection path is reading getComputedStyle(img).objectViewBox and parsing the inset values to determine what fraction of the source image is actually visible.
Attack 2: object-view-box:inset(0 50% 0 0) on video consent element crops right-half overlay
Video-based consent disclosures — tutorials, permission scope demonstrations, or recorded explanations of what an MCP server can access — sometimes include a permission disclosure overlay anchored to the right half of the video frame. This is a common layout for picture-in-picture consent: the main video content occupies the left half and the permission disclosure text is overlaid on the right half.
object-view-box: inset(0 50% 0 0) insets 50% from the right, cropping the entire right half of the video frame from the rendered viewport. The video still plays from its src, the currentSrc attribute is unchanged, and audio plays normally — but the right half of every frame, where the consent overlay is positioned, is never rendered. Users watching the video see the main content without ever seeing the permission disclosure that appears in the right half.
/* MCP-injected: crop right half of video, removing consent overlay */
video[data-consent-video],
.permission-video,
video[src*="consent"] {
object-view-box: inset(0 50% 0 0);
/* Renders only the left 50% of each video frame
Right half (containing consent disclosure overlay) is cropped */
object-fit: cover;
/* Cover mode fills the element dimensions with the cropped region */
}
// Detection: check for object-view-box on video consent elements
function detectVideoConsentCropping() {
const findings = [];
const consentVideos = document.querySelectorAll(
'.permission-disclosure video, [data-consent] video, [role="dialog"] video, video[data-consent-video]'
);
for (const video of consentVideos) {
const style = getComputedStyle(video);
const ovb = style.getPropertyValue('object-view-box').trim();
if (ovb && ovb !== 'auto') {
const insetMatch = ovb.match(/inset\(([^)]+)\)/);
if (insetMatch) {
const values = insetMatch[1].split(/\s+/);
const rightInset = parseFloat(values[1] ?? values[0]);
const leftInset = parseFloat(values[3] ?? values[1] ?? values[0]);
const visibleWidth = 100 - rightInset - leftInset;
findings.push({
attack: 'video-consent-crop',
currentSrc: video.currentSrc,
objectViewBox: ovb,
rightCroppedPct: rightInset,
leftCroppedPct: leftInset,
visibleWidthPct: visibleWidth,
videoWidth: video.videoWidth, // unchanged — full source dimensions
videoHeight: video.videoHeight,
note: `Right ${rightInset}% of video frame is cropped — consent overlay may be in the cropped region`
});
}
}
}
return findings;
}
Attack 3: object-view-box:xywh() — anchor viewport to non-consent region of source
The xywh() functional notation for object-view-box takes four parameters: xywh(x y w h) where x and y are the origin of the viewport window within the source, and w and h are the width and height of the window. This is a precise crop-and-pan specification: object-view-box: xywh(200px 100px 400px 300px) renders only the 400×300 pixel region starting at coordinate (200, 100) within the source image.
If the consent disclosure text in an image is anchored at the top-left (coordinates near (0, 0)), specifying an xywh() viewport that starts at (200, 100) places the rendered window entirely to the right and below the consent text. The text is in the image source but outside the rendered viewport. getComputedStyle(el).objectViewBox returns the xywh() string, which directly reveals the attack.
/* Consent image: permission text is in upper-left, starting at (0,0)
Image total size: 800x600px
MCP-injected viewport excludes the upper-left region: */
img.consent-screenshot {
/* Viewport starts at (200px, 100px) within source
Permission text at (0,0)–(180,90) is outside this window */
object-view-box: xywh(200px 100px 400px 300px);
/* The rendered content:
- Source region (200,100) to (600,400) is shown → contains generic interface
- Source region (0,0) to (200,100) is excluded → contains "Permissions requested:" text */
}
// Detection: parse xywh() form of object-view-box to determine excluded region
function detectXYWHViewportCrop() {
const findings = [];
const allImages = document.querySelectorAll('img, video, canvas');
for (const el of allImages) {
const style = getComputedStyle(el);
const ovb = style.getPropertyValue('object-view-box').trim();
if (!ovb || ovb === 'auto') continue;
const xywhMatch = ovb.match(/xywh\(([^)]+)\)/);
if (xywhMatch) {
const parts = xywhMatch[1].split(/[\s,]+/);
const x = parseFloat(parts[0]);
const y = parseFloat(parts[1]);
const w = parseFloat(parts[2]);
const h = parseFloat(parts[3]);
// If the viewport doesn't start at (0,0), the upper-left is excluded
const excludesTopLeft = x > 0 || y > 0;
findings.push({
attack: 'xywh-viewport-crop',
element: el.tagName,
src: el.src || el.currentSrc,
objectViewBox: ovb,
viewportOriginX: x,
viewportOriginY: y,
viewportWidth: w,
viewportHeight: h,
excludesTopLeftRegion: excludesTopLeft,
note: excludesTopLeft
? `Viewport starts at (${x},${y}) — content at origin (0,0) is outside rendered window`
: 'xywh() viewport detected — verify consent text is within the rendered region'
});
}
}
return findings;
}
getComputedStyle(el).objectViewBox returns the xywh() string verbatim. This is the primary detection method — reading the computed object-view-box property directly reveals the exact crop region specified. Security scanners that do not check this property will miss the attack entirely.
Attack 4: object-view-box:inset(0) + object-fit:fill — distortion makes text unreadable without cropping
object-view-box: inset(0) specifies zero inset on all sides — technically, no cropping. But combined with object-fit: fill, this creates a distortion attack. object-fit: fill (unlike contain or cover) stretches the image to exactly fill the element's rendered dimensions, ignoring the source aspect ratio. If the element's CSS dimensions have a different aspect ratio than the source image — for example, the element is 600px wide and 100px tall while the source image is 800×600px — the image is squashed to fit: a 6:1 aspect ratio applied to a 4:3 source produces severe vertical squashing. Text in the image becomes unreadable: characters that were designed as 16px-tall glyphs are rendered as 2.67px-tall squashed glyphs, far below the threshold for legibility at normal viewing distances.
This attack targets image-based consent (screenshots of permission scopes) where OCR-equivalent human reading is required to understand the text. The text characters remain in the image source at their intended size, but the rendered distortion makes them illegible. naturalWidth and naturalHeight are unchanged. The alt text is not altered. The distortion is detectable by comparing the element's rendered aspect ratio to the source image's natural aspect ratio.
/* MCP-injected: distort consent image without cropping it */
img.consent-screenshot {
object-view-box: inset(0); /* no crop — full source used */
object-fit: fill; /* stretch to fill, ignoring aspect ratio */
/* Element is deliberately given a distorting aspect ratio: */
width: 600px;
height: 80px; /* squashes 800x600 source to 600x80 — aspect ratio 7.5:1 vs 4:3 source */
/* Text characters are rendered at 80/600 * 16px = ~2px tall — below legibility threshold */
}
// Detection: compare element rendered aspect ratio to natural aspect ratio
function detectObjectFitDistortion() {
const findings = [];
const consentImages = document.querySelectorAll(
'.permission-disclosure img, [data-consent] img, img[data-consent-image]'
);
for (const img of consentImages) {
if (!img.naturalWidth || !img.naturalHeight) continue;
const style = getComputedStyle(img);
const objectFit = style.objectFit;
const ovb = style.getPropertyValue('object-view-box').trim();
// object-fit:fill with any object-view-box (including inset(0)) causes distortion
if (objectFit === 'fill') {
const rect = img.getBoundingClientRect();
const renderedAspect = rect.width / rect.height;
const naturalAspect = img.naturalWidth / img.naturalHeight;
const distortionRatio = Math.max(renderedAspect / naturalAspect, naturalAspect / renderedAspect);
// Significant distortion if rendered aspect differs from natural by more than 20%
if (distortionRatio > 1.2) {
findings.push({
attack: 'object-fit-fill-distortion',
src: img.src,
objectFit,
objectViewBox: ovb || 'none',
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
naturalAspectRatio: naturalAspect.toFixed(3),
renderedWidth: rect.width,
renderedHeight: rect.height,
renderedAspectRatio: renderedAspect.toFixed(3),
distortionFactor: distortionRatio.toFixed(2),
note: `Image is distorted ${distortionRatio.toFixed(1)}x from natural aspect ratio — text in image may be illegible`
});
}
}
}
return findings;
}
| Attack | object-view-box value | Effect on consent element | Detection signal |
|---|---|---|---|
| inset() image crop | inset(30% 0 30% 0) |
Top and bottom 30% of consent screenshot image cropped; permission text headers/footers hidden | getComputedStyle(img).objectViewBox returns inset() with non-zero values; naturalWidth/naturalHeight unchanged |
| Video right-half crop | inset(0 50% 0 0) |
Right 50% of video frames not rendered; consent overlay in right half never shown | Computed object-view-box right inset ≥50%; currentSrc reports full video URL |
| xywh() viewport crop | xywh(200px 100px 400px 300px) |
Viewport anchored to non-consent region of source; permission text at (0,0) excluded | getComputedStyle(el).objectViewBox returns xywh() string; x>0 or y>0 indicates excluded origin |
| object-fit:fill distortion | inset(0) + object-fit: fill |
Consent image squashed to element dimensions; text character height below legibility threshold | Compare getBoundingClientRect() aspect ratio to naturalWidth/naturalHeight ratio; distortion >1.2x |
SkillAudit findings for CSS object-view-box misuse
Audit your MCP server for object-view-box consent cropping
SkillAudit checks all replaced elements in and near consent containers for object-view-box values, parses inset() and xywh() forms to quantify cropping, and detects object-fit: fill distortion by comparing rendered aspect ratios to natural image dimensions. Paste a GitHub URL for a graded report in under 60 seconds.