CVE-2026-64728
Overview
Background
- `siteForCookies`
- the “first party for cookies” URL a document is associated with, used to decide whether an outgoing request is same-site or cross-site for
SameSitecookie enforcement. - `srcdoc` iframe
- an iframe whose content is supplied inline via the
srcdocattribute; its document URL isabout:srcdocand it inherits its security origin from its parent (owner) frame. - `SecurityPolicy::shouldInheritSecurityOriginFromOwner`
- predicate returning true for URLs like
about:srcdoc/about:blankthat must inherit their origin from the frame that created them rather than deriving it from the URL. - `SameSite` cookies
- cookies marked
StrictorLaxthat browsers withhold from cross-site requests to blunt CSRF and cross-site tracking.
Root Cause Analysis
FrameLoader::setFirstPartyForCookies walks every descendant LocalFrame and assigns each document a siteForCookies. The original code used a single condition that combined SecurityPolicy::shouldInheritSecurityOriginFromOwner(...url()) with registrableDomain.matches(...url()) and, when either was true, called setSiteForCookies(url) with the top-level frame’s first-party url. For an inherited-origin document such as a srcdoc iframe, shouldInheritSecurityOriginFromOwner is true, so the code unconditionally stamped it with the top-level site-for-cookies even when that srcdoc was nested inside a cross-origin iframe — violating the invariant that an inherited-origin document must adopt its actual parent’s first-party context.
The fix splits the branches: when the document inherits its origin from its owner, it now copies siteForCookies from its real parent LocalFrame (parent->document()->siteForCookies()) instead of from the top-level URL, so a srcdoc inside a cross-origin frame correctly carries the cross-site first party. This makes subsequent requests from that srcdoc back to the top-level origin evaluate as cross-site, causing SameSite=Strict/Lax cookies to be withheld as required.
srcdoc iframe) as belonging to the top-level first-party context rather than to its immediate parent’s, letting a cross-origin frame’s srcdoc masquerade as same-site with the top document. The fix inherits siteForCookies from the actual parent frame, restoring the correct cross-site relationship.Attack Path
- Embed cross-origin iframe
A top-level victim page at
http://127.0.0.1:8000loads (or is made to load) an attacker-controlled iframe from a cross-origin host such ashttp://localhost:8000. - Create nested srcdoc
The cross-origin attacker frame injects a nested
srcdociframe, which inherits the attacker frame’s origin but was incorrectly assigned the top-level page’ssiteForCookies. - Issue request to victim origin
Script in the
srcdocperforms a credentialedfetch()or loads an<img>back to the top-level originhttp://127.0.0.1:8000. - Same-site misclassification
Because the
srcdoccarries the top-level first party, the request is judged same-site andSameSite=Strict/Laxcookies for the victim origin are attached. - Exfiltrate cookies The attacker observes the authenticated response (or has a server recorder capture the delivered cookies), defeating the sandbox/cross-site boundary.
Impact Assessment
SameSite-restricted cookies of the embedding top-level origin on requests originating from an inherited-origin (srcdoc) subframe, enabling CSRF-style authenticated actions and cross-site information disclosure in the browser’s web-content context. The precondition is that the victim top-level page embeds attacker-influenced cross-origin framed content that can create a nested srcdoc. No memory corruption is required; the flaw is purely a same-site policy misclassification that violates iframe sandboxing expectations.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifLayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe.html |
modified |
Files Changed
LayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe-expected.txtLayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe.htmlLayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe-expected.txtLayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe.htmlLayoutTests/http/tests/cookies/same-site/resources/record-image-cookies.pyLayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-img-inside-cross-origin-iframe.htmlLayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-inside-cross-origin-iframe.htmlSource/WebCore/loader/FrameLoader.cpp
Audit Directions
- Inherited-origin first-party propagationAudit every site-for-cookies / first-party assignment that special-cases
about:srcdoc/about:blank(viashouldInheritSecurityOriginFromOwner) to confirm it inherits from the immediate parent rather than the top-level frame. - Frame-tree walks that use top-level stateReview loops over descendant frames (like
tree()traversals) that apply a single top-level value uniformly, and verify nested cross-origin ancestors are respected at each level. - SameSite classification for nested contextsTest SameSite cookie delivery for multi-level nestings (cross-origin iframe containing
srcdoc,blob:, anddata:documents) to ensure cross-site requests never adopt an ancestor’s same-site first party.
Patch
diff --git a/Source/WebCore/loader/FrameLoader.cpp b/Source/WebCore/loader/FrameLoader.cpp
index 2395f8f250f5..84df478fef01 100644
--- a/Source/WebCore/loader/FrameLoader.cpp
+++ b/Source/WebCore/loader/FrameLoader.cpp
@@ -1298,7 +1298,10 @@ void FrameLoader::setFirstPartyForCookies(const URL& url)
RefPtr localFrame = dynamicDowncast<LocalFrame>(*descendantFrame);
if (!localFrame)
continue;
- if (SecurityPolicy::shouldInheritSecurityOriginFromOwner(protect(localFrame->document())->url()) || registrableDomain.matches(protect(localFrame->document())->url()))
+ if (SecurityPolicy::shouldInheritSecurityOriginFromOwner(protect(localFrame->document())->url())) {
+ if (RefPtr parent = dynamicDowncast<LocalFrame>(localFrame->tree().parent()))
+ protect(localFrame->document())->setSiteForCookies(parent->document()->siteForCookies());
+ } else if (registrableDomain.matches(protect(localFrame->document())->url()))
protect(localFrame->document())->setSiteForCookies(url);
}
}
diff --git a/LayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe-expected.txt b/LayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe-expected.txt
new file mode 100644
index 000000000000..2cb25ce2f4a6
--- /dev/null
+++ b/LayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe-expected.txt
@@ -0,0 +1,13 @@
+Tests that SameSite=Strict and SameSite=Lax cookies for 127.0.0.1 are not sent with a fetch() initiated from a srcdoc iframe nested inside a cross-origin iframe. The srcdoc inherits its parent's origin, so a request from the srcdoc back to the top-level origin is cross-site and must not receive SameSite-restricted cookies.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+Cookies sent with cross-site fetch initiated from srcdoc iframe inside cross-origin iframe:
+PASS Do not have cookie "strict".
+PASS Do not have cookie "lax".
+PASS Has cookie "implicit-default" with value 9.
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe.html b/LayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe.html
new file mode 100644
index 000000000000..936fde8bb3e1
--- /dev/null
+++ b/LayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="/js-test-resources/js-test.js"></script>
+<script src="../resources/cookie-utilities.js"></script>
+</head>
+<body>
+<script>
+window.jsTestIsAsync = true;
+
+description("Tests that SameSite=Strict and SameSite=Lax cookies for 127.0.0.1 are not sent with a fetch() initiated from a srcdoc iframe nested inside a cross-origin iframe. The srcdoc inherits its parent's origin, so a request from the srcdoc back to the top-level origin is cross-site and must not receive SameSite-restricted cookies.");
+
+const kCookieValue = "9";
+
+function checkCookies(cookies)
+{
+ debug("Cookies sent with cross-site fetch initiated from srcdoc iframe inside cross-origin iframe:");
+ if (cookies.error) {
+ testFailed(`fetch from srcdoc failed: ${cookies.error}`);
+ return;
+ }
+ if (cookies.strict === undefined)
+ testPassed('Do not have cookie "strict".');
+ else
+ testFailed(`Should not have cookie "strict". But do with value ${cookies.strict}.`);
+
+ if (cookies.lax === undefined)
+ testPassed('Do not have cookie "lax".');
+ else
+ testFailed(`Should not have cookie "lax". But do with value ${cookies.lax}.`);
+
+ if (cookies["implicit-default"] === kCookieValue)
+ testPassed(`Has cookie "implicit-default" with value ${kCookieValue}.`);
+ else
+ testFailed(`Should have cookie "implicit-default" with value ${kCookieValue}. Got ${cookies["implicit-default"]}.`);
+}
+
+async function runTest()
+{
+ await resetCookies();
+ await setCookie("strict", kCookieValue, {"SameSite": "Strict", "Max-Age": 100, "path": "/"});
+ await setCookie("lax", kCookieValue, {"SameSite": "Lax", "Max-Age": 100, "path": "/"});
+ await setCookie("implicit-default", kCookieValue, {"SameSite": null, "Max-Age": 100, "path": "/"});
+
+ let cookiesPromise = new Promise((resolve) => {
+ window.addEventListener("message", (event) => {
+ if (event.data && event.data.type === "cookies-from-srcdoc")
+ resolve(event.data.cookies);
+ }, {once: true});
+ });
+
+ let attackerIframe = document.createElement("iframe");
+ attackerIframe.src = "http://localhost:8000/cookies/same-site/resources/srcdoc-creator-inside-cross-origin-iframe.html";
+ document.body.appendChild(attackerIframe);
+
+ let cookies = await cookiesPromise;
+ checkCookies(cookies);
+
+ document.body.removeChild(attackerIframe);
+
+ await resetCookies();
+ finishJSTest();
+}
+runTest();
+</script>
+</body>
+</html>
diff --git a/LayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe-expected.txt b/LayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe-expected.txt
new file mode 100644
index 000000000000..b1683955995f
--- /dev/null
+++ b/LayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe-expected.txt
@@ -0,0 +1,13 @@
+Tests that SameSite=Strict and SameSite=Lax cookies for 127.0.0.1 are not sent with an <img> sub-resource request initiated from a srcdoc iframe nested inside a cross-origin iframe. The srcdoc inherits its parent's origin, so the image request back to the top-level origin is cross-site and must not receive SameSite-restricted cookies.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+Cookies sent with cross-site <img> request initiated from srcdoc iframe inside cross-origin iframe:
+PASS Do not have cookie "strict".
+PASS Do not have cookie "lax".
+PASS Has cookie "implicit-default" with value 9.
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe.html b/LayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe.html
new file mode 100644
index 000000000000..16e76c2c5593
--- /dev/null
+++ b/LayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe.html
@@ -0,0 +1,97 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="/js-test-resources/js-test.js"></script>
+<script src="../resources/cookie-utilities.js"></script>
+</head>
+<body>
+<script>
+window.jsTestIsAsync = true;
+
+description("Tests that SameSite=Strict and SameSite=Lax cookies for 127.0.0.1 are not sent with an <img> sub-resource request initiated from a srcdoc iframe nested inside a cross-origin iframe. The srcdoc inherits its parent's origin, so the image request back to the top-level origin is cross-site and must not receive SameSite-restricted cookies.");
+
+const kCookieValue = "9";
+const kRecorderBase = "/cookies/same-site/resources/record-image-cookies.py";
+const kToken = "srcdoc-img-test-" + Date.now();
+
+async function fetchRecordedCookies()
+{
+ let response = await fetch(`${kRecorderBase}?mode=read&token=${encodeURIComponent(kToken)}`, {credentials: "same-origin"});
+ return response.json();
+}
+
+async function resetRecordedCookies()
+{
+ await fetch(`${kRecorderBase}?mode=reset&token=${encodeURIComponent(kToken)}`, {credentials: "same-origin"});
+}
+
+function checkCookies(cookies)
+{
+ debug("Cookies sent with cross-site <img> request initiated from srcdoc iframe inside cross-origin iframe:");
+ if (cookies.strict === undefined)
+ testPassed('Do not have cookie "strict".');
+ else
+ testFailed(`Should not have cookie "strict". But do with value ${cookies.strict}.`);
+
+ if (cookies.lax === undefined)
+ testPassed('Do not have cookie "lax".');
+ else
+ testFailed(`Should not have cookie "lax". But do with value ${cookies.lax}.`);
+
+ // Positive control: WebKit treats a cookie with no SameSite attribute as
+ // SameSite=None (see coreSameSitePolicy() in CookieCocoa.mm), so it must
+ // still be sent on a cross-site image request that includes credentials.
+ // Without this assertion, the negative checks above could pass simply
+ // because cookies aren't being delivered at all.
+ if (cookies["implicit-default"] === kCookieValue)
+ testPassed(`Has cookie "implicit-default" with value ${kCookieValue}.`);
+ else
+ testFailed(`Should have cookie "implicit-default" with value ${kCookieValue}. Got ${cookies["implicit-default"]}.`);
+}
+
+async function runTest()
+{
+ await resetCookies();
+ await resetRecordedCookies();
+ await setCookie("strict", kCookieValue, {"SameSite": "Strict", "Max-Age": 100, "path": "/"});
+ await setCookie("lax", kCookieValue, {"SameSite": "Lax", "Max-Age": 100, "path": "/"});
+ await setCookie("implicit-default", kCookieValue, {"SameSite": null, "Max-Age": 100, "path": "/"});
+
+ let imgLoadedPromise = new Promise((resolve, reject) => {
+ window.addEventListener("message", (event) => {
+ if (!event.data)
+ return;
+ if (event.data.type === "srcdoc-img-loaded")
+ resolve();
+ else if (event.data.type === "srcdoc-img-error")
+ reject(new Error("srcdoc <img> failed to load"));
+ }, {once: false});
+ });
+
+ let attackerIframe = document.createElement("iframe");
+ attackerIframe.src = "http://localhost:8000/cookies/same-site/resources/srcdoc-creator-img-inside-cross-origin-iframe.html?token=" + encodeURIComponent(kToken);
+ document.body.appendChild(attackerIframe);
+
+ try {
+ await imgLoadedPromise;
+ } catch (e) {
+ testFailed(String(e));
+ document.body.removeChild(attackerIframe);
+ await resetCookies();
+ finishJSTest();
+ return;
+ }
+
+ let cookies = await fetchRecordedCookies();
+ checkCookies(cookies);
+
+ document.body.removeChild(attackerIframe);
+
+ await resetRecordedCookies();
+ await resetCookies();
+ finishJSTest();
+}
+runTest();
+</script>
+</body>
+</html>
diff --git a/LayoutTests/http/tests/cookies/same-site/resources/record-image-cookies.py b/LayoutTests/http/tests/cookies/same-site/resources/record-image-cookies.py
new file mode 100755
index 000000000000..0795ddd34a90
--- /dev/null
+++ b/LayoutTests/http/tests/cookies/same-site/resources/record-image-cookies.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+#
+# Helper for img-from-srcdoc-iframe-inside-cross-origin-iframe.html
+# (rdar://175498842 / https://bugs.webkit.org/show_bug.cgi?id=313220).
+#
+# Modes:
+# ?mode=reset&token=<id> ............ clear any previously recorded cookies for <id>
+# ?mode=record&token=<id> ........... record incoming Cookie header to a temp file keyed by <id>; respond with a 1x1 PNG
+# ?mode=read&token=<id> ............. respond with JSON of cookies recorded for <id>
+
+import json
+import os
+import sys
+import tempfile
+from urllib.parse import parse_qs
+
+file = __file__.split(':/cygwin')[-1]
+http_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(os.path.dirname(file)))))
+sys.path.insert(0, http_root)
+
+from resources.portabilityLayer import get_cookies, get_state, set_state
+
+query = parse_qs(os.environ.get('QUERY_STRING', ''), keep_blank_values=True)
+mode = query.get('mode', ['record'])[0]
+token = query.get('token', ['default'])[0]
+tmp_file = os.path.join(tempfile.gettempdir(), 'srcdoc-img-cookies-' + token + '.json')
+
+origin = os.environ.get('HTTP_ORIGIN', '*')
+
+if mode == 'reset':
+ set_state(tmp_file, '{}')
+ sys.stdout.write(
+ 'Content-Type: text/plain\r\n'
+ 'Cache-Control: no-store\r\n'
+ 'Access-Control-Allow-Origin: {}\r\n'
+ 'Access-Control-Allow-Credentials: true\r\n\r\n'
+ 'reset'.format(origin)
+ )
+ sys.exit(0)
+
+if mode == 'read':
+ cookies_json = get_state(tmp_file, '{}')
+ sys.stdout.write(
+ 'Content-Type: application/json\r\n'
+ 'Cache-Control: no-store\r\n'
+ 'Access-Control-Allow-Origin: {}\r\n'
+ 'Access-Control-Allow-Credentials: true\r\n\r\n'
+ '{}'.format(origin, cookies_json)
+ )
+ sys.exit(0)
+
+# mode == 'record' (default): record incoming cookies and serve a 1x1 transparent PNG.
+cookies = get_cookies()
+set_state(tmp_file, json.dumps(cookies))
+
+png = bytes.fromhex(
+ '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489'
+ '0000000d4944415478da63f8ffff3f0005fe02fe2c8c5d420000000049454e44ae'
+ '426082'
+)
+sys.stdout.buffer.write(
+ ('Content-Type: image/png\r\n'
+ 'Cache-Control: no-store\r\n'
+ 'Content-Length: {}\r\n\r\n'.format(len(png))).encode()
+)
+sys.stdout.buffer.write(png)
+sys.exit(0)
diff --git a/LayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-img-inside-cross-origin-iframe.html b/LayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-img-inside-cross-origin-iframe.html
new file mode 100644
index 000000000000..219c37fbd6df
--- /dev/null
+++ b/LayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-img-inside-cross-origin-iframe.html
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Attacker iframe (cross-origin to the top-level victim) that creates a srcdoc loading an img</title>
+</head>
+<body>
+<script>
+// Loaded at http://localhost:8000 inside an iframe of a top-level page at
+// http://127.0.0.1:8000. Creates a nested srcdoc iframe; the srcdoc loads an
+// <img> back to the top-level origin and reports load completion to the top
+// frame. The top frame then reads the cookies the image request actually saw
+// from a server-side recorder.
+//
+// rdar://175498842 / https://bugs.webkit.org/show_bug.cgi?id=313220
+
+const params = new URLSearchParams(window.location.search);
+const token = params.get("token") || "default";
+const recorderURL = "http://127.0.0.1:8000/cookies/same-site/resources/record-image-cookies.py?mode=record&token=" + encodeURIComponent(token);
+
+const srcdocContent = `<!DOCTYPE html><body><script>
+let img = new Image();
+img.onload = () => window.top.postMessage({type: "srcdoc-img-loaded"}, "*");
+img.onerror = () => window.top.postMessage({type: "srcdoc-img-error"}, "*");
+img.src = ${JSON.stringify(recorderURL)};
+<\/script></body>`;
+
+let srcdocIframe = document.createElement("iframe");
+srcdocIframe.style.display = "none";
+srcdocIframe.srcdoc = srcdocContent;
+document.body.appendChild(srcdocIframe);
+</script>
+</body>
+</html>
diff --git a/LayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-inside-cross-origin-iframe.html b/LayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-inside-cross-origin-iframe.html
new file mode 100644
index 000000000000..8d2002468a56
--- /dev/null
+++ b/LayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-inside-cross-origin-iframe.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Cross-origin iframe that creates a srcdoc</title>
+</head>
+<body>
+<script>
+const fetchURL = "http://127.0.0.1:8000/cookies/resources/echo-json.py";
+const srcdocContent = `<!DOCTYPE html><body><script>
+fetch(${JSON.stringify(fetchURL)}, {credentials: "include", mode: "cors"})
+ .then((response) => response.json())
+ .then((cookies) => {
+ window.top.postMessage({type: "cookies-from-srcdoc", cookies}, "*");
+ })
+ .catch((error) => {
+ window.top.postMessage({type: "cookies-from-srcdoc", cookies: {error: String(error)}}, "*");
+ });
+<\/script></body>`;
+
+let srcdocIframe = document.createElement("iframe");
+srcdocIframe.style.display = "none";
+srcdocIframe.srcdoc = srcdocContent;
+document.body.appendChild(srcdocIframe);
+</script>
+</body>
+</html>
Regression Test / PoC
diff --git a/LayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe-expected.txt b/LayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe-expected.txt
new file mode 100644
index 000000000000..2cb25ce2f4a6
--- /dev/null
+++ b/LayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe-expected.txt
@@ -0,0 +1,13 @@
+Tests that SameSite=Strict and SameSite=Lax cookies for 127.0.0.1 are not sent with a fetch() initiated from a srcdoc iframe nested inside a cross-origin iframe. The srcdoc inherits its parent's origin, so a request from the srcdoc back to the top-level origin is cross-site and must not receive SameSite-restricted cookies.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+Cookies sent with cross-site fetch initiated from srcdoc iframe inside cross-origin iframe:
+PASS Do not have cookie "strict".
+PASS Do not have cookie "lax".
+PASS Has cookie "implicit-default" with value 9.
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe.html b/LayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe.html
new file mode 100644
index 000000000000..936fde8bb3e1
--- /dev/null
+++ b/LayoutTests/http/tests/cookies/same-site/fetch-in-srcdoc-iframe-inside-cross-origin-iframe.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="/js-test-resources/js-test.js"></script>
+<script src="../resources/cookie-utilities.js"></script>
+</head>
+<body>
+<script>
+window.jsTestIsAsync = true;
+
+description("Tests that SameSite=Strict and SameSite=Lax cookies for 127.0.0.1 are not sent with a fetch() initiated from a srcdoc iframe nested inside a cross-origin iframe. The srcdoc inherits its parent's origin, so a request from the srcdoc back to the top-level origin is cross-site and must not receive SameSite-restricted cookies.");
+
+const kCookieValue = "9";
+
+function checkCookies(cookies)
+{
+ debug("Cookies sent with cross-site fetch initiated from srcdoc iframe inside cross-origin iframe:");
+ if (cookies.error) {
+ testFailed(`fetch from srcdoc failed: ${cookies.error}`);
+ return;
+ }
+ if (cookies.strict === undefined)
+ testPassed('Do not have cookie "strict".');
+ else
+ testFailed(`Should not have cookie "strict". But do with value ${cookies.strict}.`);
+
+ if (cookies.lax === undefined)
+ testPassed('Do not have cookie "lax".');
+ else
+ testFailed(`Should not have cookie "lax". But do with value ${cookies.lax}.`);
+
+ if (cookies["implicit-default"] === kCookieValue)
+ testPassed(`Has cookie "implicit-default" with value ${kCookieValue}.`);
+ else
+ testFailed(`Should have cookie "implicit-default" with value ${kCookieValue}. Got ${cookies["implicit-default"]}.`);
+}
+
+async function runTest()
+{
+ await resetCookies();
+ await setCookie("strict", kCookieValue, {"SameSite": "Strict", "Max-Age": 100, "path": "/"});
+ await setCookie("lax", kCookieValue, {"SameSite": "Lax", "Max-Age": 100, "path": "/"});
+ await setCookie("implicit-default", kCookieValue, {"SameSite": null, "Max-Age": 100, "path": "/"});
+
+ let cookiesPromise = new Promise((resolve) => {
+ window.addEventListener("message", (event) => {
+ if (event.data && event.data.type === "cookies-from-srcdoc")
+ resolve(event.data.cookies);
+ }, {once: true});
+ });
+
+ let attackerIframe = document.createElement("iframe");
+ attackerIframe.src = "http://localhost:8000/cookies/same-site/resources/srcdoc-creator-inside-cross-origin-iframe.html";
+ document.body.appendChild(attackerIframe);
+
+ let cookies = await cookiesPromise;
+ checkCookies(cookies);
+
+ document.body.removeChild(attackerIframe);
+
+ await resetCookies();
+ finishJSTest();
+}
+runTest();
+</script>
+</body>
+</html>
diff --git a/LayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe-expected.txt b/LayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe-expected.txt
new file mode 100644
index 000000000000..b1683955995f
--- /dev/null
+++ b/LayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe-expected.txt
@@ -0,0 +1,13 @@
+Tests that SameSite=Strict and SameSite=Lax cookies for 127.0.0.1 are not sent with an <img> sub-resource request initiated from a srcdoc iframe nested inside a cross-origin iframe. The srcdoc inherits its parent's origin, so the image request back to the top-level origin is cross-site and must not receive SameSite-restricted cookies.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+Cookies sent with cross-site <img> request initiated from srcdoc iframe inside cross-origin iframe:
+PASS Do not have cookie "strict".
+PASS Do not have cookie "lax".
+PASS Has cookie "implicit-default" with value 9.
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe.html b/LayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe.html
new file mode 100644
index 000000000000..16e76c2c5593
--- /dev/null
+++ b/LayoutTests/http/tests/cookies/same-site/img-from-srcdoc-iframe-inside-cross-origin-iframe.html
@@ -0,0 +1,97 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="/js-test-resources/js-test.js"></script>
+<script src="../resources/cookie-utilities.js"></script>
+</head>
+<body>
+<script>
+window.jsTestIsAsync = true;
+
+description("Tests that SameSite=Strict and SameSite=Lax cookies for 127.0.0.1 are not sent with an <img> sub-resource request initiated from a srcdoc iframe nested inside a cross-origin iframe. The srcdoc inherits its parent's origin, so the image request back to the top-level origin is cross-site and must not receive SameSite-restricted cookies.");
+
+const kCookieValue = "9";
+const kRecorderBase = "/cookies/same-site/resources/record-image-cookies.py";
+const kToken = "srcdoc-img-test-" + Date.now();
+
+async function fetchRecordedCookies()
+{
+ let response = await fetch(`${kRecorderBase}?mode=read&token=${encodeURIComponent(kToken)}`, {credentials: "same-origin"});
+ return response.json();
+}
+
+async function resetRecordedCookies()
+{
+ await fetch(`${kRecorderBase}?mode=reset&token=${encodeURIComponent(kToken)}`, {credentials: "same-origin"});
+}
+
+function checkCookies(cookies)
+{
+ debug("Cookies sent with cross-site <img> request initiated from srcdoc iframe inside cross-origin iframe:");
+ if (cookies.strict === undefined)
+ testPassed('Do not have cookie "strict".');
+ else
+ testFailed(`Should not have cookie "strict". But do with value ${cookies.strict}.`);
+
+ if (cookies.lax === undefined)
+ testPassed('Do not have cookie "lax".');
+ else
+ testFailed(`Should not have cookie "lax". But do with value ${cookies.lax}.`);
+
+ // Positive control: WebKit treats a cookie with no SameSite attribute as
+ // SameSite=None (see coreSameSitePolicy() in CookieCocoa.mm), so it must
+ // still be sent on a cross-site image request that includes credentials.
+ // Without this assertion, the negative checks above could pass simply
+ // because cookies aren't being delivered at all.
+ if (cookies["implicit-default"] === kCookieValue)
+ testPassed(`Has cookie "implicit-default" with value ${kCookieValue}.`);
+ else
+ testFailed(`Should have cookie "implicit-default" with value ${kCookieValue}. Got ${cookies["implicit-default"]}.`);
+}
+
+async function runTest()
+{
+ await resetCookies();
+ await resetRecordedCookies();
+ await setCookie("strict", kCookieValue, {"SameSite": "Strict", "Max-Age": 100, "path": "/"});
+ await setCookie("lax", kCookieValue, {"SameSite": "Lax", "Max-Age": 100, "path": "/"});
+ await setCookie("implicit-default", kCookieValue, {"SameSite": null, "Max-Age": 100, "path": "/"});
+
+ let imgLoadedPromise = new Promise((resolve, reject) => {
+ window.addEventListener("message", (event) => {
+ if (!event.data)
+ return;
+ if (event.data.type === "srcdoc-img-loaded")
+ resolve();
+ else if (event.data.type === "srcdoc-img-error")
+ reject(new Error("srcdoc <img> failed to load"));
+ }, {once: false});
+ });
+
+ let attackerIframe = document.createElement("iframe");
+ attackerIframe.src = "http://localhost:8000/cookies/same-site/resources/srcdoc-creator-img-inside-cross-origin-iframe.html?token=" + encodeURIComponent(kToken);
+ document.body.appendChild(attackerIframe);
+
+ try {
+ await imgLoadedPromise;
+ } catch (e) {
+ testFailed(String(e));
+ document.body.removeChild(attackerIframe);
+ await resetCookies();
+ finishJSTest();
+ return;
+ }
+
+ let cookies = await fetchRecordedCookies();
+ checkCookies(cookies);
+
+ document.body.removeChild(attackerIframe);
+
+ await resetRecordedCookies();
+ await resetCookies();
+ finishJSTest();
+}
+runTest();
+</script>
+</body>
+</html>
diff --git a/LayoutTests/http/tests/cookies/same-site/resources/record-image-cookies.py b/LayoutTests/http/tests/cookies/same-site/resources/record-image-cookies.py
new file mode 100755
index 000000000000..0795ddd34a90
--- /dev/null
+++ b/LayoutTests/http/tests/cookies/same-site/resources/record-image-cookies.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+#
+# Helper for img-from-srcdoc-iframe-inside-cross-origin-iframe.html
+# (rdar://175498842 / https://bugs.webkit.org/show_bug.cgi?id=313220).
+#
+# Modes:
+# ?mode=reset&token=<id> ............ clear any previously recorded cookies for <id>
+# ?mode=record&token=<id> ........... record incoming Cookie header to a temp file keyed by <id>; respond with a 1x1 PNG
+# ?mode=read&token=<id> ............. respond with JSON of cookies recorded for <id>
+
+import json
+import os
+import sys
+import tempfile
+from urllib.parse import parse_qs
+
+file = __file__.split(':/cygwin')[-1]
+http_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(os.path.dirname(file)))))
+sys.path.insert(0, http_root)
+
+from resources.portabilityLayer import get_cookies, get_state, set_state
+
+query = parse_qs(os.environ.get('QUERY_STRING', ''), keep_blank_values=True)
+mode = query.get('mode', ['record'])[0]
+token = query.get('token', ['default'])[0]
+tmp_file = os.path.join(tempfile.gettempdir(), 'srcdoc-img-cookies-' + token + '.json')
+
+origin = os.environ.get('HTTP_ORIGIN', '*')
+
+if mode == 'reset':
+ set_state(tmp_file, '{}')
+ sys.stdout.write(
+ 'Content-Type: text/plain\r\n'
+ 'Cache-Control: no-store\r\n'
+ 'Access-Control-Allow-Origin: {}\r\n'
+ 'Access-Control-Allow-Credentials: true\r\n\r\n'
+ 'reset'.format(origin)
+ )
+ sys.exit(0)
+
+if mode == 'read':
+ cookies_json = get_state(tmp_file, '{}')
+ sys.stdout.write(
+ 'Content-Type: application/json\r\n'
+ 'Cache-Control: no-store\r\n'
+ 'Access-Control-Allow-Origin: {}\r\n'
+ 'Access-Control-Allow-Credentials: true\r\n\r\n'
+ '{}'.format(origin, cookies_json)
+ )
+ sys.exit(0)
+
+# mode == 'record' (default): record incoming cookies and serve a 1x1 transparent PNG.
+cookies = get_cookies()
+set_state(tmp_file, json.dumps(cookies))
+
+png = bytes.fromhex(
+ '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489'
+ '0000000d4944415478da63f8ffff3f0005fe02fe2c8c5d420000000049454e44ae'
+ '426082'
+)
+sys.stdout.buffer.write(
+ ('Content-Type: image/png\r\n'
+ 'Cache-Control: no-store\r\n'
+ 'Content-Length: {}\r\n\r\n'.format(len(png))).encode()
+)
+sys.stdout.buffer.write(png)
+sys.exit(0)
diff --git a/LayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-img-inside-cross-origin-iframe.html b/LayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-img-inside-cross-origin-iframe.html
new file mode 100644
index 000000000000..219c37fbd6df
--- /dev/null
+++ b/LayoutTests/http/tests/cookies/same-site/resources/srcdoc-creator-img-inside-cross-origin-iframe.html
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Attacker iframe (cross-origin to the top-level victim) that creates a srcdoc loading an img</title>
+</head>
+<body>
+<script>
... (truncated)