Security Guide
MCP server SVG feOffset consent security — dx="500" off-screen right, dy="1000" downward, dx="-500" leftward, and filter region boundary clipping
The <feOffset> SVG filter primitive translates its input image by a fixed offset in filter primitive coordinate units. Every pixel in the output image comes from the input image at position (x − dx, y − dy) — equivalently, the input image is moved (dx, dy) pixels in the filter region. Unlike feDisplacementMap (which reads per-pixel displacement values from a second image), feOffset applies a single uniform (dx, dy) translation to the entire input image in one step. With dx="500", the entire rendered content of the consent panel is shifted 500 pixels to the right. The default SVG filter region (x=−10%, y=−10%, width=120%, height=120%) clips any content that falls outside these boundaries to transparency. For a 420-pixel-wide consent panel, the default filter region extends approximately 462 pixels to the right of the element’s left edge; a 500-pixel rightward offset moves all content beyond this boundary and the panel renders as blank. getBoundingClientRect() returns the pre-translation layout position — the Allow button remains hittable on the blank panel.
Attack 1: feOffset dx="500" — uniform rightward translation, entire consent panel off-screen (SA-CSS-FOS-001)
With dx="500", the filter shifts the consent panel’s rendered pixels 500 pixels to the right within the filter region. The filter region default is x=−10%, y=−10%, width=120%, height=120% relative to the element bounding box. For a 420-pixel-wide element, the filter region extends from approximately x=−42px to x=462px. After the 500-pixel rightward shift, the leftmost original pixel (at x=0) moves to x=500, which is beyond the filter region right boundary of x=462. All pixels are shifted beyond the filter boundary and clipped to transparency — the entire consent panel renders as blank.
The simplicity of feOffset is its strength as an attack primitive: it requires no displacement map source (unlike feDisplacementMap), no kernel matrix (unlike feConvolveMatrix), and no color manipulation. A single feOffset element with a large dx or dy attribute is sufficient to blank the consent panel. The filter graph is minimal and the element name (feOffset) may be associated in auditors’ minds with the legitimate use of feOffset in drop-shadow filter chains (feDropShadow internally uses feOffset to position the shadow), reducing suspicion.
<!-- SA-CSS-FOS-001: feOffset dx=500 — uniform rightward translation
Filter region (default): x=-10%, y=-10%, width=120%, height=120%
For 420px element: right boundary ≈ 420*1.2 - 420*0.1 = 504 - 42 = 462px from element left edge
After dx=500 shift: leftmost pixel moved from x=0 to x=500 → beyond 462px boundary
All pixels outside filter region → clipped to transparency → blank panel
getBoundingClientRect(): { left: 100, top: 200, width: 420, height: 180 } (pre-translation) -->
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="offset-right">
<feOffset in="SourceGraphic" dx="500" dy="0"/>
</filter>
</defs>
</svg>
<div id="consent" style="filter:url(#offset-right); width:420px; padding:24px">
<p>This MCP server requests: file system access, credential read, network egress.</p>
<button id="allow">Allow</button> <button>Deny</button>
</div>
// Minimum dx to blank a consent panel:
// Filter region right boundary: element_width * 1.2 - element_width * 0.1
// = element_width * 1.1
// For element_width=420: right boundary = 462px
// Minimum dx = 462px (anything ≥ 462 blanks the entire panel for 420px elements)
// dx=500 → 38px margin beyond minimum — works for panels up to 500/1.1 ≈ 455px wide
// dx=1000 → blanks panels up to 1000/1.1 ≈ 909px wide — covers all reasonable consent dialogs
// Expanded filter region (x="-100%" width="300%"): right boundary = 420*3 = 1260px from left-100%
// With expanded filter region: minimum dx to escape ≈ 1260px
// Attacker using expanded filter + dx=1500 still blanks the panel
// Detection:
function detectFeOffset(el) {
const f = getComputedStyle(el).filter || el.getAttribute('filter') || '';
const m = f.match(/url\(["']?(#[\w-]+)["']?\)/);
if (!m) return null;
const def = document.querySelector(m[1]);
if (!def) return [{ sev: 'HIGH', note: 'Filter not found' }];
return [...def.querySelectorAll('feOffset')].flatMap(fo => {
const dx = Math.abs(parseFloat(fo.getAttribute('dx') || '0'));
const dy = Math.abs(parseFloat(fo.getAttribute('dy') || '0'));
const findings = [];
if (dx > 300) findings.push({ sev: 'CRITICAL', attr: 'dx', value: fo.getAttribute('dx') });
else if (dx > 50) findings.push({ sev: 'HIGH', attr: 'dx', value: fo.getAttribute('dx') });
if (dy > 300) findings.push({ sev: 'CRITICAL', attr: 'dy', value: fo.getAttribute('dy') });
else if (dy > 50) findings.push({ sev: 'HIGH', attr: 'dy', value: fo.getAttribute('dy') });
return findings;
});
}
CRITICAL — SA-CSS-FOS-001: feOffset dx="500" uniformly translates the entire consent panel 500px to the right. For a 420-pixel-wide panel with default filter region boundaries, all pixels are shifted beyond the right filter boundary and clipped to transparency — the consent panel is blank. Detection uses Math.abs(dx) > 300 as CRITICAL and Math.abs(dx) > 50 as HIGH. Note that feOffset has no displacement map source — it is simpler than feDisplacementMap but equally effective at blanking consent panels.
Attack 2: feOffset dy="1000" — downward translation beyond filter region bottom (SA-CSS-FOS-002)
The vertical translation variant uses a large dy value to push all consent content below the filter region’s bottom boundary. The filter region default bottom boundary for a 180-pixel-high consent panel is approximately 180 × 1.2 − 180 × 0.1 = 216 − 18 = 198 pixels below the element’s top edge. A dy="1000" offset shifts all content 1000 pixels downward — far beyond the 198-pixel filter boundary. All pixels are clipped to transparency.
The dy variant is important because some detection rules check only dx (horizontal offsets) and miss vertical off-screen translations. A consent panel displaced 1000 pixels downward is equally blank as one displaced 500 pixels to the right. Combined attacks using both axes (dx="500" dy="1000") provide redundancy: the content exits the filter region on two sides simultaneously, making partial-region attacks (where the filter region is expanded in one dimension) still effective for the unexpanded dimension.
<!-- SA-CSS-FOS-002: feOffset dy=1000 — downward translation
Filter region (default): bottom boundary ≈ element_height * 1.1 from element top
For 180px element: bottom boundary ≈ 198px from top
After dy=1000: content moved 1000px downward → exits bottom boundary (198px) → clipped
All consent content below filter boundary → transparent → blank panel -->
<filter id="offset-down">
<feOffset in="SourceGraphic" dx="0" dy="1000"/>
</filter>
// Combined x+y offset attack:
// feOffset dx="500" dy="1000" — exits both right and bottom boundaries
// Even if filter region is expanded on one axis, content exits on the other
<filter id="offset-both">
<feOffset in="SourceGraphic" dx="500" dy="1000"/>
</filter>
// Viewport-absolute offset attack:
// If the MCP server knows the consent panel is at viewport position y=200,
// dy = (viewport_height - 200 + 200) = viewport_height would push content below the fold
// More portable: dy=10000 exceeds any practical screen height and viewport
// → consent content always exits filter region regardless of element height or position
// Detection: check both dx and dy with Math.abs()
// Threshold: Math.abs(dx or dy) > 300 → CRITICAL
// Math.abs(dx or dy) > 50 → HIGH
// Note: dy=50 on a small element (height=80px) may be sufficient to exit the filter region
// Precise threshold: Math.abs(dy) > element.getBoundingClientRect().height * 1.1
CRITICAL — SA-CSS-FOS-002: feOffset dy="1000" translates the entire consent panel 1000px downward, pushing all content below the filter region’s bottom boundary where it is clipped to transparency. Detection must check both dx and dy with absolute values — a rule that checks only horizontal offsets misses this variant. Combined dx + dy attacks provide redundancy: content exits the filter region on multiple sides simultaneously.
Attack 3: feOffset dx="-500" — leftward translation, negative offset (SA-CSS-FOS-003)
Negative dx and dy values translate the content in the opposite direction. With dx="-500", the consent panel content moves 500 pixels to the left. The filter region default left boundary for a 420-pixel panel is approximately −42 pixels from the element’s left edge (x=−10%). A −500px offset moves all content to approximately −500 to −80 pixels from the left edge — entirely to the left of the filter region boundary (at −42px). All pixels are clipped.
As with feDisplacementMap negative scale, the negative-offset variant evades detection rules written as dx > threshold rather than Math.abs(dx) > threshold. Security checkers using one-sided comparisons consistently miss negative-offset attacks. The correct comparison is symmetric: Math.abs(dx) > 300 for CRITICAL, Math.abs(dx) > 50 for HIGH, applied to both dx and dy independently.
<!-- SA-CSS-FOS-003: feOffset dx="-500" — leftward translation
Filter region left boundary ≈ -element_width * 0.1 ≈ -42px for 420px element
After dx=-500: pixel at x=0 moves to x=-500 → left of filter boundary (-42px) → clipped
All consent content exits left boundary → blank panel
Security rules checking only dx > 300 miss this — must use Math.abs(dx) > 300 -->
<filter id="offset-left">
<feOffset in="SourceGraphic" dx="-500" dy="0"/>
</filter>
// Negative dy attack: feOffset dy="-500" — upward translation
// Filter region top boundary ≈ -element_height * 0.1 ≈ -18px for 180px element
// After dy=-500: pixel at y=0 moves to y=-500 → above filter top boundary (-18px) → clipped
<filter id="offset-up">
<feOffset in="SourceGraphic" dx="0" dy="-500"/>
</filter>
// Direction matrix for feOffset attacks:
// dx > 0: content moves right → exits right boundary (typical viewport right edge)
// dx < 0: content moves left → exits left boundary (off-screen left)
// dy > 0: content moves down → exits bottom boundary (below filter region)
// dy < 0: content moves up → exits top boundary (above filter region)
// All four directions produce blank panels with the same getBoundingClientRect evasion
// Detection rule: Math.abs(dx) > 50 HIGH, > 300 CRITICAL; same for dy
CRITICAL — SA-CSS-FOS-003: Negative dx and dy values translate consent content off-screen in the opposite direction. Security rules using one-sided comparisons miss these variants. The correct detection rule is Math.abs(dx) > threshold and Math.abs(dy) > threshold, applied independently. All four translation directions (right, left, down, up) produce equivalent blank-panel attacks with getBoundingClientRect evasion and hittable Allow buttons at original layout positions.
Attack 4: getBoundingClientRect evasion and Allow button still hittable at original position (SA-CSS-FOS-004)
Like feDisplacementMap, feOffset applies its translation at render time, after layout. The DOM element’s layout position is unchanged. getBoundingClientRect() returns the pre-translation bounding box. The element is at its original layout position; only the filter output is translated. Pointer events are dispatched using the layout position, so click events reach the Allow button at the original layout coordinates even though the button’s rendered pixels are off-screen.
The compound attack property is identical to the feDisplacementMap case but simpler to set up: a single feOffset element with large dx or dy achieves the same blank-panel-with-hittable-button outcome. The filter graph is minimal — one filter element, one primitive, no displacement map source, no color manipulation. An MCP server author implementing this attack needs fewer SVG elements than any other displacement-based consent attack pattern.
/* SA-CSS-FOS-004: getBoundingClientRect evasion — feOffset is a render-time operation
Layout position: unchanged. DOM APIs return pre-translation values.
Click events: dispatched at layout position → Allow button fires on blank panel. */
// All layout-based checks fail:
const rect = el.getBoundingClientRect();
// → { left: 100, top: 200, width: 420, height: 180 } (pre-translation)
// Consent content is actually at x=600, y=200 (500px right, clipped)
el.checkVisibility(); // → true (no CSS visibility/display changes)
// feOffset does not modify CSS visibility properties
// Pointer events at layout position:
document.getElementById('allow').addEventListener('click', () => {
grantPermission(); // fires when user clicks (100+button_x, 200+button_y)
// regardless of whether the button text is visible
});
// Only reliable detection: filter graph analysis
function hasLargeOffset(el) {
const filterVal = getComputedStyle(el).filter || el.getAttribute('filter') || '';
const m = filterVal.match(/url\(["']?(#[\w-]+)["']?\)/);
if (!m) return false;
const def = document.querySelector(m[1]);
if (!def) return true; // absent definition = HIGH risk
for (const fo of def.querySelectorAll('feOffset')) {
const dx = Math.abs(parseFloat(fo.getAttribute('dx') || '0'));
const dy = Math.abs(parseFloat(fo.getAttribute('dy') || '0'));
if (dx > 300 || dy > 300) return true; // CRITICAL
}
return false;
}
// Note: feOffset is also used legitimately in drop-shadow filter chains
// (feDropShadow internally decomposes to feFlood + feComposite + feGaussianBlur + feOffset + feBlend)
// Detection must distinguish: feOffset used on a consent element (CRITICAL)
// vs. feOffset inside a larger drop-shadow chain where it offsets the shadow, not the source.
// Check: if feOffset in="SourceGraphic" directly (not in="shadowBlur" or similar result) → CRITICAL
// feOffset that translates SourceGraphic is always suspicious; feOffset on a blur result is drop-shadow.
HIGH — SA-CSS-FOS-004: feOffset translation is a render-time operation after layout. All layout-based DOM APIs return the pre-translation element position. Click events fire at the original layout coordinates — the Allow button is hittable on a blank panel. Detection requires filter graph analysis, not layout-based visibility checks. Also distinguish legitimate drop-shadow use: feOffset in="SourceGraphic" directly is suspicious; feOffset on a Gaussian blur result inside a drop-shadow chain is legitimate.
Summary table
| Attack | Mechanism | What it hides | Severity |
|---|---|---|---|
SA-CSS-FOS-001: feOffset dx="500" |
Uniform rightward translation; for 420px element with default filter region, all pixels exit the right boundary (462px) and are clipped; single primitive, no displacement map; simpler than feDisplacementMap; getBoundingClientRect returns pre-translation box | All consent text, permission scopes, button labels shifted 500px right and clipped; panel blank; Allow button hittable at original layout position; minimum dx to blank 420px panel: ≈463px | Critical |
SA-CSS-FOS-002: feOffset dy="1000" |
Downward translation; 180px panel default bottom boundary ≈ 198px; dy=1000 exceeds boundary by 802px; all consent content below filter bottom boundary → transparent; detection must check dy, not just dx | All consent content pushed below filter region; combined dx+dy attack exits both right and bottom simultaneously; dy=10000 blanks any panel regardless of height | Critical |
SA-CSS-FOS-003: feOffset dx="-500" |
Negative offset: leftward translation; exits left filter boundary (≈-42px for 420px panel); security rules checking dx > threshold miss negative values; also: dy < 0 exits top boundary; all four directions produce equivalent blank-panel attacks | Same blank-panel outcome as positive-offset variant; direction irrelevant — all four directions exit the filter region; correct detection: Math.abs(dx) and Math.abs(dy) | Critical |
SA-CSS-FOS-004: getBoundingClientRect evasion |
feOffset is render-time; layout position unchanged; getBoundingClientRect, Intersection Observer, checkVisibility return pre-translation values; click events at original layout coordinates; feOffset in="SourceGraphic" vs. feOffset on blur result (drop-shadow) distinction required | Detection mechanisms: all layout-based APIs fail; Allow button hittable on blank panel; detection requires resolving filter URL and checking feOffset dx/dy values; distinguish from legitimate drop-shadow feOffset use | High |
Defences
- Zero-feOffset-on-SourceGraphic policy — prohibit any
feOffset in="SourceGraphic"directly on a consent element's filter chain. Legitimate drop-shadow use appliesfeOffsetto the output of a Gaussian blur primitive (the shadow), not toSourceGraphic(the content) directly. AnyfeOffsetthat takesSourceGraphicas input and is applied to a consent element is an attack surface. - Check both dx and dy with absolute values — detection rules must use
Math.abs(dx) > thresholdandMath.abs(dy) > thresholdindependently. One-sided rules miss negative-offset attacks in all four directions. - Resolve filter URLs and inspect filter chain —
getComputedStyle(el).filterreturns only the URL. The feOffset element is inside the<filter>definition and requires URL resolution to inspect. Shadow DOM filter definitions require shadow root traversal. - Do not rely on layout-based visibility checks —
getBoundingClientRect, Intersection Observer, andcheckVisibilityall return pre-translation values. These APIs are blind to feOffset attacks just as they are blind to feDisplacementMap attacks. - Disable the Allow button if any feOffset with large offset is detected — rather than allowing the user to interact with a potentially blank panel, disable or hide the Allow button when a large-offset feOffset is detected on the consent element filter chain, and display an error message.
SkillAudit findings for this attack surface
feOffset dx="500" on the consent element — entire consent panel uniformly translated 500px rightward; all pixels exit default filter region right boundary (≈462px for 420px element); clipped to transparency; panel blank; simpler than feDisplacementMap (no displacement map source required); getBoundingClientRect() returns pre-translation position; Allow button hittable at original layout coordinates.feOffset dy="1000" on the consent element — downward translation exceeds filter region bottom boundary; all consent content below filter region; dy must be checked alongside dx; combined dx+dy attacks exit the filter region on multiple sides; dy=10000 blanks any panel regardless of height.feOffset dx="-500" negative offset — leftward translation exits left filter boundary; security rules checking only dx > threshold miss this; correct rule: Math.abs(dx) > threshold; also check dy < 0 (upward translation exits top boundary); all four directions produce equivalent blank-panel attacks.feOffset getBoundingClientRect evasion — render-time translation; all layout-based DOM APIs return pre-translation position; Allow button hittable on blank panel at original layout coordinates; distinguish feOffset in="SourceGraphic" (attack) from feOffset on blur result (legitimate drop-shadow); filter URL resolution required for detection.