LCP renderTime should have 4ms granularity
CVE: CVE-2026-64713 · Safari 26.6 · Released July 27, 2026 Impact: Websites may know if the user has visited a given link Apple's description: This issue was addressed with improved checks. Credit: Kwak Kiyong, Song Nuri
Medium — the entry-construction site clamped one script-visible timestamp and shipped its sibling raw. No memory-safety primitive falls out of it; what falls out is a finer clock than the engine intends any page to hold, pointed straight at cross-origin resource state.
Browsers spend real engineering effort making sure that no clock reachable from page script is precise enough to time a cache hit. That effort is centralised in a small set of reduced-resolution helpers on Performance, and every timestamp a page can read is supposed to pass through one of them on its way out. LargestContentfulPaintData builds the performance entries that describe the biggest image or text block painted in the viewport, and it populates two timestamps on each entry — the moment the underlying resource finished loading, and the moment the candidate was painted. Only one of them was being coarsened.
The angle: A page can read an uncoarsened paint timestamp for an image it caused to load, giving it a finer measurement channel than the engine's other clocks — enough to infer whether a cross-origin resource was already cached, and by extension whether the user has visited a given link.
Source/WebCore/page/LargestContentfulPaintData.cpp
Patch Details
One function changes: LargestContentfulPaintData::potentiallyAddLargestContentfulPaintEntry(). Structurally the edit is a relocation, not an addition of new logic. Before, the if (image) block closed after setting the entry's URL string and load time, and several lines later — outside the branch, past the element-ID handling — a single unconditional pendingEntry->setRenderTime(paintTimestamp) served both image and text candidates. After, that unconditional call is deleted and the assignment is pushed into both arms of an if (image) / else split.
The image arm gains three things: a local reduceResolution lambda that floors a Seconds value onto a caller-supplied grid, a static constexpr auto renderTimeSecondsResolution = 4_ms naming that grid, and a setRenderTime call that wraps paintTimestamp in Seconds::fromMilliseconds(), pushes it through the lambda, and converts back with .milliseconds(). A comment cites the LCP spec section mandating the 4ms figure for images, and a FIXME records that the hand-rolled floor is a placeholder pending adoption of ReducedResolutionSeconds under webkit.org/b/316824. The else arm keeps the previous behaviour verbatim: text candidates still store the raw paintTimestamp.
Before: After:
if (image) { if (image) {
setURLString(...) setURLString(...)
setLoadTime(reduced(loadTime)) ✓ setLoadTime(reduced(loadTime)) ✓
} setRenderTime(floor4ms(paint)) ✓
... } else
setRenderTime(paintTimestamp) ✗ setRenderTime(paintTimestamp)
└─► script reads full-res clock ...
Nothing else in the file moves; the trailing LOG_WITH_STREAM that dumps renderTime for the LCP logging channel is untouched and now reports the coarsened value for images.
Background
Largest Contentful Paint. LCP is a Performance Timeline metric that reports the largest image or text block painted in the viewport. As rendering proceeds, candidates are evaluated and the winning one produces a LargestContentfulPaint entry, which is delivered to page JavaScript through PerformanceObserver. Any page can subscribe; no permission, no user gesture, no special context.
The entry's two timestamps. Each entry carries loadTime — when the candidate's underlying resource finished loading — and renderTime — when the candidate was painted. Both are DOMHighResTimeStamp values: floating-point milliseconds measured from the document's time origin. For an image candidate the entry also carries the resource URL and the computed size.
Reduced time resolution. Engines deliberately clamp script-visible timestamps to a coarse grid, so page code cannot assemble a high-precision clock out of the timing APIs. In WebCore this machinery lives on Performance: relativeTimeFromTimeOriginInReducedResolution() converts a monotonic time into a time-origin-relative DOMHighResTimeStamp with the clamp already applied, and the ReducedResolutionSeconds type is the newer, harder-to-bypass expression of the same idea. This is the same policy family that governs performance.now() clamping.
Cache-timing inference. A resource served from the memory or network cache becomes available measurably sooner than one fetched over the network. Any timestamp precise enough to resolve that gap can be used to distinguish the two states — the standard building block for "did this user already visit that site" style history probing. Cross-origin servers opt into detailed resource timing via the Timing-Allow-Origin header; the absence of that opt-in is what makes uncoarsened timing on a cross-origin resource a boundary violation rather than a convenience.
WTF Seconds and the _ms literal. Seconds is WTF's typed duration wrapper; 4_ms is its millisecond literal suffix. Seconds::fromMilliseconds() lifts a raw DOMHighResTimeStamp into the typed value and .milliseconds() drops back out, which is why the patched line has a conversion on each side of the flooring step.
Execution flow. During paint the rendering code calls potentiallyAddLargestContentfulPaintEntry() with the element, its optional CachedImage, the geometry rects, a load time and a paint timestamp. The function first filters candidates — empty rect, user scroll, input already dispatched, effective visual area compared against m_largestPaintArea — and only then constructs a LargestContentfulPaint entry and populates its fields.
Analysis
This is a per-field sanitisation gap: the boundary between the engine's internal clock and page-visible script is guarded field-by-field at the construction site rather than at the type or the emit path, and one field's guard was never written.
paintTimestamp ──┐ loadTime ──┐
│ │
│ relativeTimeFromTimeOrigin
│ InReducedResolution() ◄── clamp
│ │
▼ ▼
setRenderTime(raw) setLoadTime(reduced)
│ │
└──────────► LargestContentfulPaint ◄┘
│
PerformanceObserver
▼
page JavaScript
Read the two columns of the diagram against the pre-fix source and the asymmetry is stark. loadTime and paintTimestamp originate the same way, land on the same object, and reach script through the same observer callback — but only the left-hand path skipped the clamp. The correct helper was already present in the function, one line above the omission. That proximity is the whole reason this survived review: a reader scanning potentiallyAddLargestContentfulPaintEntry() sees relativeTimeFromTimeOriginInReducedResolution() in the image block, registers that the function is resolution-aware, and does not go on to check per-field coverage. The unconditional setRenderTime(paintTimestamp) sitting fifteen lines further down, outside the if (image) block entirely, is spatially divorced from the code that would have prompted the question.
What the omission hands a page is a measurement channel, not a memory primitive. renderTime for an image candidate is the moment the engine finished painting a resource whose availability depends on state the page is not otherwise permitted to observe — most directly, whether that resource was already in cache. At full resolution the value can be differenced against loadTime on the same entry, against startTime on other entries, or against performance.now(), and each of those differences is a sub-frame-precision reading of work the page did not do itself. The LCP spec's #sec-report-largest-contentful-paint mandates the 4ms grid for images precisely so those differences are not measurable at that precision. Apple's advisory frames the impact as "websites may know if the user has visited a given link," which is the cache-inference primitive followed one step: probe a resource characteristic of a target site, time the paint, read whether it was already cached.
The fix restores the invariant by flooring the image renderTime onto the 4ms grid before it is ever written into the entry. The text arm keeps full resolution deliberately — a text candidate is same-origin document content, not a separately-fetched cross-origin resource, so its paint moment does not encode the same cross-origin state. That reasoning is sound as far as the branch discriminant goes, though it is worth noting the discriminant is image, not "cross-origin-dependent", and the two are not identical predicates in every case.
Two details of the landed mitigation deserve attention from anyone treating it as a bound rather than as conformance. The coarsening is a bare std::floor(value / resolution) * resolution — a pure function of the input with no entropy anywhere in the expression:
auto reduceResolution = [](Seconds value, Seconds resolution) {
return Seconds(std::floor(value.value() / resolution.value()) * resolution.value());
};
Deterministic quantisation removes precision from a single sample but not from a population of them: an attacker who can shift the phase of the measured event relative to the grid and average across many trials recovers precision below the grid step. And the FIXME is explicit that this lambda is a placeholder until ReducedResolutionSeconds is adopted, which makes the follow-up bug the one that determines the real residual precision. What landed here is Blink parity and spec conformance — the field is no longer raw, which was the actual defect.
A privacy clamp applied to one field of a script-visible record and omitted on its sibling left LCP image paint timestamps at full resolution — a cache-state oracle handed to any page.
Insight
Entry-construction sites that populate several script-visible timestamps are where this asymmetry hides, because the correct sanitiser does appear in the function and reviewers pattern-match on its presence rather than on per-field coverage. If a record has N externally-visible fields and the sanitiser is invoked N times, the audit question is always "which invocation is missing?" — and the durable fix direction is to move the transform into the type or the emit path so it cannot be skipped, which is exactly what adopting ReducedResolutionSeconds would accomplish here.
Audit directions
-
Per-field sanitisation where the boundary should own the transform. Narrow: walk the other Performance Timeline entry constructors in
Source/WebCore/page—PerformanceResourceTiming,PerformanceElementTiming,PerformanceEventTiming,PerformancePaintTiming,PerformanceNavigationTiming— and confirm everyDOMHighResTimeStampsetter argument comes fromrelativeTimeFromTimeOriginInReducedResolution()orreduceTimeResolution(); the tell is asetXxxTime(...)whose argument is a raw parameter or a plain arithmetic expression instead of a helper's return value. Wider: the same shape recurs wherever a struct is assembled field-by-field before crossing a trust boundary — IPC encoders that validate some arguments withMESSAGE_CHECKbut not all, DOM binding wrappers that origin-check one accessor of a multi-property interface, serialization paths that redact only some fields of a diagnostic payload; in search results, look for a run of consecutive setter calls where only a subset of arguments is wrapped. Widest: this is the general per-field-instead-of-per-boundary sanitisation class and it holds in any system with a marshalling layer — Chromium's Mojo struct traits, protobuf/JSON serializers with field-level redaction, ORM DTO mappers masking PII column by column. -
Deterministic quantisation used as a side-channel mitigation. The invariant: a clamp removes k bits of precision only if the quantisation phase is unpredictable to the attacker. Narrow: compare the new
reduceResolutionlambda against the engine's canonical path —Performance::reduceTimeResolutionand theReducedResolutionSecondswork tracked at webkit.org/b/316824 — and establish whether the canonical path adds jitter or is also a bare floor; the tell isstd::floor(value / resolution) * resolutionwith no entropy source in the expression. Wider: any privacy clamp implemented by truncation elsewhere in WebCore/WTF — rounded device-pixel-ratio reporting, quantiseddeviceMemory/hardwareConcurrency-style hints, truncated geolocation or sensor sampling rates, anystd::floor/std::round/bit-mask applied to a value specifically because script can read it; the shape to notice is a rounding operation whose comment cites privacy or spec-mandated coarsening rather than layout or units. Widest: the "deterministic quantisation as privacy mitigation" class covers differential-privacy noise budgets, k-anonymity bucketing, fixed-window rate limiting, and clock clamping in V8 and SpiderMonkey. Ask two questions of each: does the implementation draw any randomness, and is that randomness per-origin/per-session so it cannot be averaged out either? -
Guard applied to one arm of a type discriminant, sibling arm left permissive. Narrow: in
potentiallyAddLargestContentfulPaintEntry()the image arm coarsensrenderTimeand theelsearm does not — trace which callers reach the text arm and confirm text candidates cannot carry cross-origin-dependent load timing. Webfont fetches are the interesting case, since text paint can be gated on a font load; the tell is any path where a!imagecandidate's paint moment still depends on a separately-fetched subresource. Wider: the same shape recurs wherever WebCore branches on resource provenance to decide how much detail to expose — TAO-gated fields in resource timing, same-origin vs. cross-origin branches in error reporting andSecurityOriginchecks, cross-origin-tainted canvas readback; look for anif (isSameOrigin)/if (image)/if (passesTimingAllowCheck)split where only one arm calls a redaction helper. Widest: "the exemption arm inherits the old permissive behaviour" shows up in any system where policy is retrofitted onto a discriminated union — feature-flag rollouts applied only to the new path, migration shims that sanitise v2 payloads and pass v1 through, permission checks added to one subclass override but not its siblings. After adding a guard to one branch, enumerate the siblings and demand a written reason for each exemption rather than assuming the prior behaviour was safe.