← Blog

MCP server SVG feBlend all HSL modes unified detection consent security

Unified detection algorithm for all four feBlend HSL-decomposition modes — hue, saturation, color, and luminosity — against MCP consent-text attacks. Three of the four modes (hue, saturation, color) share a common L-from-in2 property enabling a single threshold-based check; mode=luminosity is the exception requiring a separate analysis path. Includes complete scanner pseudocode for static DOM analysis and runtime MutationObserver coverage.

By SkillAudit  |  24 Oct 2026

Scanners that detect SVG feBlend consent attacks must handle 16 distinct feBlend modes: normal, multiply, screen, overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, exclusion, hue, saturation, color, and luminosity. The final four — hue, saturation, color, luminosity — are the HSL-decomposition family. They share a common mathematical structure but differ in which HSL components they take from in1 (source) vs in2 (backdrop).

A scanner that checks each HSL mode independently requires four separate code paths. This page presents a unified detection algorithm that handles all four modes through a single L-analysis function with per-mode branching, covering all three attack scenarios (white-out, dark-out, contrast-collapse) while correctly excluding false positives.

HSL component assignment across all four modes

ModeH fromS fromL fromL-from-in2?Achromatic in2 attack?
huein1 (source)in2 (backdrop)in2 (backdrop)YesYes — L=L(in2)
saturationin2 (backdrop)in1 (source)in2 (backdrop)YesYes — L=L(in2)
colorin1 (source)in1 (source)in2 (backdrop)YesYes — L=L(in2)
luminosityin2 (backdrop)in2 (backdrop)in1 (source)NoNo — L=L(source)

The three modes hue, saturation, and color all take L from in2 (the feFlood backdrop). For all three, the output luminance = L(feFlood). This is the shared property that enables a unified detection check: parse L from in2, threshold it, flag accordingly. Mode=luminosity takes L from in1 (source/consent text), so the output luminance is preserved from the original — achromatic in2 does not change it.

The 3+1 split: Hue, saturation, and color all take L from in2. Luminosity takes L from in1. A unified check for in2 L-value covers three modes. Luminosity requires a separate analysis. Applying the unified check to luminosity generates false positives for achromatic in2 (which is benign for luminosity). Do not conflate all four modes.

Shared attack matrix for hue, saturation, and color

/* For modes: hue, saturation, color — ALL take L from in2 */

function computeOutputL_HSC(mode, L_source, L_in2) {
  /* For hue, saturation, color: output L = L(in2) regardless of L_source */
  return L_in2;
}

/* Attack thresholds (WCAG-derived, linearised RGB) */
WHITE_OUT_THRESHOLD = 0.70    /* L > 0.70 → output > 0.70 → WCAG vs white < 1.4:1 */
DARK_OUT_THRESHOLD  = 0.15    /* L < 0.15 → output < 0.15 → WCAG vs black < 1.3:1 */
CONTRAST_FAIL_LOW   = 0.30    /* L in [0.30, 0.65] → WCAG vs white in [1.5:1, 3.7:1] */
CONTRAST_FAIL_HIGH  = 0.65

function checkHSCMode(mode, L_in2, bgColor) {
  if (L_in2 > WHITE_OUT_THRESHOLD) {
    return { severity: "CRITICAL", type: "white-out",
             message: `feBlend mode="${mode}" + feFlood L=${L_in2.toFixed(2)}: output at L=${L_in2.toFixed(2)}; WCAG vs white ≈${wcagVsWhite(L_in2).toFixed(1)}:1` };
  }
  if (L_in2 < DARK_OUT_THRESHOLD) {
    return { severity: "CRITICAL", type: "dark-out",
             message: `feBlend mode="${mode}" + feFlood L=${L_in2.toFixed(2)}: output at L=${L_in2.toFixed(2)}; WCAG vs black ≈${wcagVsBlack(L_in2).toFixed(1)}:1` };
  }
  if (L_in2 > CONTRAST_FAIL_LOW && L_in2 < CONTRAST_FAIL_HIGH) {
    const wcag = wcagVsBackground(L_in2, bgColor);
    if (wcag < 4.5) {
      return { severity: "HIGH", type: "contrast-collapse",
               message: `feBlend mode="${mode}" + feFlood L=${L_in2.toFixed(2)}: output L=${L_in2.toFixed(2)}; WCAG vs bg ${wcag.toFixed(1)}:1 < 4.5:1` };
    }
  }
  return null; /* no finding */
}

Separate path for mode=luminosity

/* For mode: luminosity — takes L from in1 (source), H+S from in2 */

function computeOutputL_luminosity(L_source, L_in2) {
  /* Luminosity: output L = L(source) NOT L(in2) */
  /* Achromatic in2: output L preserved from source → no L-collapse attack */
  return L_source;
}

function checkLuminosityMode(L_in2, L_source, S_in2, H_in2, bgColor) {
  /* Achromatic in2 (S_in2 ≈ 0): output L = L_source; no L-manipulation */
  /* Only attack path for luminosity: chromatic in2 that shifts perceived luminance */
  /* via hue/saturation override while preserving L_source numerically */
  /* This is a more subtle attack (opponent-color channels) — separate analysis needed */

  /* For achromatic in2: return null (no finding) */
  if (S_in2 < 0.05) {
    return null; /* true negative for luminosity + achromatic in2 */
  }

  /* For chromatic in2: evaluate whether the H+S override from in2 */
  /* combined with L from source creates a camouflage attack */
  /* (e.g. in2 with same H+S as background → consent text blends in) */
  /* This is a different attack surface — see luminosity-specific analysis */
  return checkLuminosityChromaticIn2(L_source, S_in2, H_in2, bgColor);
}

Complete unified HSL mode detection pseudocode

/* Entry point: called for any feBlend with HSL mode in consent ancestor filter */

function detectHSLModeAttack(blendNode, consentAncestor, domContext) {
  const mode = blendNode.getAttribute("mode");
  const in2Node = resolveIn2(blendNode, domContext);

  /* Parse in2 feFlood to HSL */
  const floodColor = in2Node.getAttribute("flood-color");
  const floodOpacity = parseFloat(in2Node.getAttribute("flood-opacity") || "1");
  const { H, S, L } = parseColorToHSL(floodColor);

  /* Effective L after opacity blend (opacity-0 in2 has no effect) */
  const effectiveL = floodOpacity < 0.05 ? null : L;

  /* Background luminance for WCAG simulation */
  const bgL = getComputedBackgroundLuminance(consentAncestor);

  /* Mode routing */
  if (mode === "hue" || mode === "saturation" || mode === "color") {
    if (effectiveL === null) {
      /* Opacity 0: check for JS mutation attack */
      watchForMutation(in2Node, blendNode, consentAncestor, mode);
      return null;
    }
    return checkHSCMode(mode, effectiveL, bgL);

  } else if (mode === "luminosity") {
    if (effectiveL === null) {
      watchForMutation(in2Node, blendNode, consentAncestor, mode);
      return null;
    }
    return checkLuminosityMode(effectiveL, null, S, H, bgL);
  }

  return null;
}

/* MutationObserver: watch for click-time attacks */
function watchForMutation(floodNode, blendNode, consentAncestor, mode) {
  const observer = new MutationObserver(mutations => {
    for (const m of mutations) {
      if (m.attributeName === "flood-opacity" || m.attributeName === "flood-color") {
        const newOpacity = parseFloat(floodNode.getAttribute("flood-opacity") || "1");
        if (newOpacity > 0.05) {
          const { H, S, L } = parseColorToHSL(floodNode.getAttribute("flood-color"));
          const bgL = getComputedBackgroundLuminance(consentAncestor);
          const finding = mode === "luminosity"
            ? checkLuminosityMode(L, null, S, H, bgL)
            : checkHSCMode(mode, L, bgL);
          if (finding) {
            reportFinding({ ...finding, trigger: "interaction", mode, timestamp: Date.now() });
          }
        }
      }
    }
  });
  observer.observe(floodNode, { attributes: true, attributeFilter: ["flood-opacity", "flood-color"] });
  observer.observe(blendNode, { attributes: true, attributeFilter: ["mode"] });
}

Per-mode attack and detection summary

ModeL sourceWhite in2 (L=1)Black in2 (L=0)Gray in2 (L=0.5)Detect via
huein2White-out (Critical)Dark-out (Critical)Collapse (High)checkHSCMode(L_in2)
saturationin2White-out (Critical)Dark-out (Critical)Collapse (High)checkHSCMode(L_in2)
colorin2White-out (Critical)Dark-out (Critical)Collapse (High)checkHSCMode(L_in2)
luminosityin1No attack (L preserved)No attack (L preserved)No attack (L preserved)checkLuminosityMode (separate)

False positive prevention: luminosity + achromatic in2

A scanner that applies the HSC mode check (L from in2) to mode=luminosity will flag luminosity+white as Critical white-out. This is incorrect: luminosity+white preserves source luminance. Dark text remains dark. The false positive erodes detector credibility and may lead operators to disable the detection rule entirely — a worse outcome than missing it.

/* FALSE POSITIVE example — do NOT do this */
if (["hue","saturation","color","luminosity"].includes(mode)) {
  /* Wrong: applies unified L(in2) check to luminosity too */
  const L = parseLuminance(floodColor);
  if (L > 0.70) flag("white-out"); // FALSE for luminosity + white!
}

/* CORRECT: mode-specific routing */
if (mode === "hue" || mode === "saturation" || mode === "color") {
  /* These take L from in2 — check L(in2) */
  checkHSCMode(mode, parseLuminance(floodColor));
} else if (mode === "luminosity") {
  /* Takes L from in1 — achromatic in2 is benign; separate path */
  checkLuminosityMode(parseLuminance(floodColor), parseSaturation(floodColor), ...);
}

Detection coverage summary (all feBlend modes)

With the HSL modes covered by this page, the complete feBlend detection list now covers all 16 modes:

/* feBlend mode detection coverage as of 2026-10-24 */
const FEBLEND_MODES_COVERED = {
  /* Arithmetic/compositing modes */
  "normal":      "identity; benign in most cases",
  "multiply":    "dark-out at Cb=0; separate from darken",
  "screen":      "white-out at Cb=1; floor-raise at near-white",
  "overlay":     "asymmetric: dark<0.5 → multiply; light>0.5 → screen",
  "darken":      "min(Cs,Cb); dark-out at Cb=0",
  "lighten":     "max(Cs,Cb); white-out at Cb=1",
  "color-dodge": "Cs/(1-Cb); white at Cb→1; singularity",
  "color-burn":  "1-(1-Cs)/Cb; black at Cb→0; singularity",
  "hard-light":  "overlay with in1/in2 swapped",
  "soft-light":  "softer version of overlay",
  "difference":  "|Cs-Cb|; inversion at Cb=1; collapse at Cb=0.5",
  "exclusion":   "Cs+Cb-2CsCb; softer difference; same endpoints",

  /* HSL decomposition modes */
  "hue":         "L from in2; unified L-check; false-positive: none",
  "saturation":  "L from in2; unified L-check; false-positive: none",
  "color":       "L from in2; unified L-check; works for chromatic in2",
  "luminosity":  "L from in1; separate path; achromatic in2 benign",
};
/* All 16 modes covered. Complete coverage achieved. */

Milestone: With the addition of difference, hue, saturation, color, and luminosity mode analysis, all 16 feBlend modes are now covered in SkillAudit's consent-attack detection surface. The unified HSL check (this page) reduces the HSL family to a single L-analysis function with one branch point, enabling efficient and complete coverage without per-mode code duplication for the three modes that share L-from-in2 semantics.

Defences

SkillAudit findings for this attack surface

CRITICAL Unified: feBlend mode=hue|saturation|color with feFlood L>0.70 — all three modes take L from in2; output L=L(in2); white-out for L(in2)>0.70; applies to any flood-color (achromatic or chromatic) with L in the critical range; three separate mode detection entries required despite identical analysis function; false positive risk: none.
CRITICAL Unified: feBlend mode=hue|saturation|color with feFlood L<0.15 — output L=L(in2)<0.15; dark-out on dark background; all three modes; separate entries.
HIGH Unified: feBlend mode=hue|saturation|color with feFlood L in [0.30, 0.65] — contrast-collapse; WCAG fails at this L range; use checkHSCMode with WCAG simulation against computed background luminance of consent ancestor.
MEDIUM False-positive risk: applying unified L(in2) check to feBlend mode=luminosity — luminosity takes L from in1 (source), not in2; achromatic in2 with luminosity is benign; applying the HSC check generates false positives; scanner must route luminosity to separate analysis path.

Individual mode pages: feBlend mode=hue  |  feBlend mode=saturation  |  feBlend mode=color  |  feBlend mode=luminosity

← Blog  |  Security Checklist