Skip to content

Commit

Permalink
PCM: Add capability for click destination to fire triggering event wi…
Browse files Browse the repository at this point in the history
…thout cross-site requests to the click source

https://bugs.webkit.org/show_bug.cgi?id=233173
<rdar://79426605>

Reviewed by Alex Christensen.

Source/WebCore:

This patch enables click destination sites a non-JavaScript way to fire triggering
events without a requirement to make cross-site requests to source sites. This is
referred to as a "same-site pixel API" and has been discussed in W3C Privacy CG:
privacycg/private-click-measurement#71

The reason why some merchants want such an "API" is reluctance to deploy new
JavaScript on their sites. In some industries it's even a compliance issue. Legacy
"pixels" are however accepted and so a same-site "pixel" can work for them.

Test: http/tests/privateClickMeasurement/triggering-event-with-attribution-source-through-fetch-keepalive.html

* html/HTMLAnchorElement.cpp:
(WebCore::HTMLAnchorElement::handleClick):
    This change is because of clarification in naming:
    - attributionReportSourceURL to attributionReportClickSourceURL
* loader/PrivateClickMeasurement.cpp:
(WebCore::PrivateClickMeasurement::parseAttributionRequestQuery):
    New function that parses out query string parameters.
(WebCore::PrivateClickMeasurement::parseAttributionRequest):
    Now calls the new PrivateClickMeasurement::parseAttributionRequestQuery()
    which handles data coming in in query parameters, in this case
    the new parameter "attributionSource."
(WebCore::PrivateClickMeasurement::attributionReportClickSourceURL const):
    New name.
(WebCore::PrivateClickMeasurement::attributionReportClickDestinationURL const):
    New name.
(WebCore::PrivateClickMeasurement::attributionReportJSON const):
    Now uses the constant privateClickMeasurementVersion.
(WebCore::PrivateClickMeasurement::tokenSignatureJSON const):
    Now uses the constant privateClickMeasurementVersion.
(WebCore::PrivateClickMeasurement::attributionReportSourceURL const): Deleted.
    Renamed attributionReportClickSourceURL.
(WebCore::PrivateClickMeasurement::attributionReportAttributeOnURL const): Deleted.
    Renamed attributionReportClickDestinationURL.
* loader/PrivateClickMeasurement.h:
(WebCore::PrivateClickMeasurement::sourceSecretToken const):
    New name.
(WebCore::PrivateClickMeasurement::AttributionTriggerData::encode const):
(WebCore::PrivateClickMeasurement::AttributionTriggerData::decode):
    Encoding and decoding of the new field sourceRegistrableDomain.
(WebCore::PrivateClickMeasurement::sourceUnlinkableToken const): Deleted.
    Renamed sourceSecretToken.
    Note that it was always the secret token used, just bad renaming earlier.

Source/WebKit:

This patch enables click destination sites a non-JavaScript way to fire triggering
events without a requirement to make cross-site requests to source sites. This is
referred to as a "same-site pixel API" and has been discussed in W3C Privacy CG:
privacycg/private-click-measurement#71

The reason why some merchants want such an "API" is reluctance to deploy new
JavaScript on their sites. In some industries it's even a compliance issue. Legacy
"pixels" are however accepted and so a same-site "pixel" can work for them.

* NetworkProcess/PrivateClickMeasurement/PrivateClickMeasurementDatabase.cpp:
(WebKit::PCM::Database::insertPrivateClickMeasurement):
    These changes are just a correction of a function name:
    - sourceUnlinkableToken() to sourceSecretToken()
    Note that it was always the secret token used, just bad renaming earlier.
* NetworkProcess/PrivateClickMeasurement/PrivateClickMeasurementManager.cpp:
(WebKit::PrivateClickMeasurementManager::handleAttribution):
    Now checks if the incoming WebCore::PrivateClickMeasurement::AttributionTriggerData
    carries a sourceRegistrableDomain. If so, it accepts that domain as the source site
    for attribution if the triggering event was same-site as the first-party.
(WebKit::PrivateClickMeasurementManager::attribute):
(WebKit::PrivateClickMeasurementManager::fireConversionRequest):
    These changes are just a correction of a function name:
    - sourceUnlinkableToken() to sourceSecretToken()
    Note that it was always the secret token used, just bad renaming earlier.
(WebKit::PrivateClickMeasurementManager::fireConversionRequestImpl):
    These changes are because of clarification in naming:
    - attributionReportSourceURL to attributionReportClickSourceURL
    - attributionReportAttributeOnURL to attributionReportClickDestinationURL
* NetworkProcess/PrivateClickMeasurement/PrivateClickMeasurementManager.h:
* NetworkProcess/PrivateClickMeasurement/PrivateClickMeasurementStore.cpp:
(WebKit::PCM::Store::attributePrivateClickMeasurement):
* NetworkProcess/PrivateClickMeasurement/PrivateClickMeasurementStore.h:

Source/WTF:

* wtf/URL.cpp:
(WTF::queryParameters):
    New convenience getter.
* wtf/URL.h:

Tools:

These changes are just a correction of a function name:
sourceUnlinkableToken() to sourceSecretToken()

* TestWebKitAPI/Tests/WebCore/PrivateClickMeasurement.cpp:
(TestWebKitAPI::TEST):
* TestWebKitAPI/Tests/WebCore/cocoa/PrivateClickMeasurementCocoa.mm:
(TestWebKitAPI::TEST):

LayoutTests:

* http/tests/privateClickMeasurement/resources/redirectToConversionWithAttributionSource.py: Added.
* http/tests/privateClickMeasurement/triggering-event-with-attribution-source-through-fetch-keepalive-expected.txt: Added.
* http/tests/privateClickMeasurement/triggering-event-with-attribution-source-through-fetch-keepalive.html: Added.



Canonical link: https://commits.webkit.org/244367@main
git-svn-id: https://svn.webkit.org/repository/webkit/trunk@285967 268f45cc-cd09-0410-ab3c-d52691b4dbfc
  • Loading branch information
johnwilander committed Nov 18, 2021
1 parent 477430c commit c1c5ecb
Show file tree
Hide file tree
Showing 20 changed files with 344 additions and 49 deletions.
12 changes: 12 additions & 0 deletions LayoutTests/ChangeLog
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
2021-11-17 John Wilander <[email protected]>

PCM: Add capability for click destination to fire triggering event without cross-site requests to the click source
https://bugs.webkit.org/show_bug.cgi?id=233173
<rdar://79426605>

Reviewed by Alex Christensen.

* http/tests/privateClickMeasurement/resources/redirectToConversionWithAttributionSource.py: Added.
* http/tests/privateClickMeasurement/triggering-event-with-attribution-source-through-fetch-keepalive-expected.txt: Added.
* http/tests/privateClickMeasurement/triggering-event-with-attribution-source-through-fetch-keepalive.html: Added.

2021-11-17 Ryan Haddad <[email protected]>

[iOS] imported/w3c/web-platform-tests/html/semantics/embedded-content/the-iframe-element/iframe_sandbox_window_open_download_block_downloads.tentative.html is frequently failing
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env python3

import os
import sys
import time
from urllib.parse import parse_qs

query = parse_qs(os.environ.get('QUERY_STRING', ''), keep_blank_values=True)
delay_ms = query.get('delay_ms', [None])[0]
conversion_data = query.get('conversionData', [None])[0]
priority = query.get('priority', [None])[0]

if delay_ms is not None:
time.sleep(int(delay_ms) * 0.001)

sys.stdout.write(
'status: 302\r\n'
'Cache-Control: no-cache, no-store, must-revalidate\r\n'
'Access-Control-Allow-Origin: *\r\n'
'Access-Control-Allow-Methods: GET\r\n'
'Content-Type: text/html\r\n'
)

if conversion_data is not None and priority is not None:
sys.stdout.write('Location: /.well-known/private-click-measurement/trigger-attribution/{}/{}?attributionSource=https://127.0.0.1\r\n'.format(conversion_data, priority))
elif conversion_data is not None:
sys.stdout.write('Location: /.well-known/private-click-measurement/trigger-attribution/{}?attributionSource=https://127.0.0.1\r\n'.format(conversion_data))

sys.stdout.write('\r\n')
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
CONSOLE MESSAGE: [Private Click Measurement] Conversion was not accepted because the conversion data could not be parsed or was higher than the allowed maximum of 15.
CONSOLE MESSAGE: Origin http://localhost:8000 is not allowed by Access-Control-Allow-Origin. Status code: 404
CONSOLE MESSAGE: Fetch API cannot load https://localhost:8443/.well-known/private-click-measurement/trigger-attribution/Dummy?attributionSource=https://127.0.0.1 due to access control checks.
Tests triggering of private click measurement attribution with same-site triggering event request.


Attributed Private Click Measurements:
WebCore::PrivateClickMeasurement 1
Source site: 127.0.0.1
Attribute on site: localhost
Source ID: 3
Attribution trigger data: 12
Attribution priority: 0
Attribution earliest time to send: Within 24-48 hours
Application bundle identifier: testBundleID
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script src="/js-test-resources/ui-helper.js"></script>
<script src="resources/util.js"></script>
</head>
<body onload="runTest()">
<div id="description">Tests triggering of private click measurement attribution with same-site triggering event request.</div>
<a id="targetLink" href="http://localhost:8000/privateClickMeasurement/triggering-event-with-attribution-source-through-fetch-keepalive.html?stepTwo" attributionsourceid=3 attributiondestination="http://localhost:8000">Link</a><br>
<div id="output"></div>
<script>
prepareTest();

function activateElement(elementID) {
var element = document.getElementById(elementID);
var centerX = element.offsetLeft + element.offsetWidth / 2;
var centerY = element.offsetTop + element.offsetHeight / 2;
UIHelper.activateAt(centerX, centerY).then(
function () {
},
function () {
document.getElementById("output").innerText = "FAIL Promise rejected.";
tearDownAndFinish();
}
);
}

function triggerFetch(conversionData) {
return fetch("https://localhost:8443/privateClickMeasurement/resources/redirectToConversionWithAttributionSource.py?conversionData="+ conversionData + "&delay_ms=100", { keepalive: true });
}

function runTest() {
if (window.location.search === "?stepTwo") {
// Start private click attribution fetch but navigate away before the fetch redirection happens.
triggerFetch(12);
document.location.href = "http://localhost:8000/privateClickMeasurement/triggering-event-with-attribution-source-through-fetch-keepalive.html?stepThree";
return;
}
if (window.location.search === "?stepThree") {
document.body.removeChild(document.getElementById("targetLink"));
// Do an invalid private click attribution fetch to ensure the previous correct click attribution fetch will be finished.
triggerFetch("Dummy").catch(() => {
if (window.testRunner)
testRunner.dumpPrivateClickMeasurement();
tearDownAndFinish();
});
return;
}
testRunner.setPrivateClickMeasurementAppBundleIDForTesting("testBundleID");
activateElement("targetLink");
}
</script>
</body>
</html>
13 changes: 13 additions & 0 deletions Source/WTF/ChangeLog
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
2021-11-17 John Wilander <[email protected]>

PCM: Add capability for click destination to fire triggering event without cross-site requests to the click source
https://bugs.webkit.org/show_bug.cgi?id=233173
<rdar://79426605>

Reviewed by Alex Christensen.

* wtf/URL.cpp:
(WTF::queryParameters):
New convenience getter.
* wtf/URL.h:

2021-11-17 Chris Dumez <[email protected]>

Web Locks API does get enabled in Service Workers when running layout tests
Expand Down
5 changes: 5 additions & 0 deletions Source/WTF/wtf/URL.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,11 @@ bool URL::hostIsIPAddress(StringView host)

#endif

Vector<KeyValuePair<String, String>> queryParameters(const URL& url)
{
return URLParser::parseURLEncodedForm(url.query());
}

Vector<KeyValuePair<String, String>> differingQueryParameters(const URL& firstURL, const URL& secondURL)
{
auto firstQueryParameters = URLParser::parseURLEncodedForm(firstURL.query());
Expand Down
1 change: 1 addition & 0 deletions Source/WTF/wtf/URL.h
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ bool operator!=(const String&, const URL&);
WTF_EXPORT_PRIVATE bool equalIgnoringFragmentIdentifier(const URL&, const URL&);
WTF_EXPORT_PRIVATE bool protocolHostAndPortAreEqual(const URL&, const URL&);
WTF_EXPORT_PRIVATE Vector<KeyValuePair<String, String>> differingQueryParameters(const URL&, const URL&);
WTF_EXPORT_PRIVATE Vector<KeyValuePair<String, String>> queryParameters(const URL&);
WTF_EXPORT_PRIVATE bool isEqualIgnoringQueryAndFragments(const URL&, const URL&);
WTF_EXPORT_PRIVATE void removeQueryParameters(URL&, const HashSet<String>&);

Expand Down
52 changes: 52 additions & 0 deletions Source/WebCore/ChangeLog
Original file line number Diff line number Diff line change
@@ -1,3 +1,55 @@
2021-11-17 John Wilander <[email protected]>

PCM: Add capability for click destination to fire triggering event without cross-site requests to the click source
https://bugs.webkit.org/show_bug.cgi?id=233173
<rdar://79426605>

Reviewed by Alex Christensen.

This patch enables click destination sites a non-JavaScript way to fire triggering
events without a requirement to make cross-site requests to source sites. This is
referred to as a "same-site pixel API" and has been discussed in W3C Privacy CG:
https://github.com/privacycg/private-click-measurement/issues/71

The reason why some merchants want such an "API" is reluctance to deploy new
JavaScript on their sites. In some industries it's even a compliance issue. Legacy
"pixels" are however accepted and so a same-site "pixel" can work for them.

Test: http/tests/privateClickMeasurement/triggering-event-with-attribution-source-through-fetch-keepalive.html

* html/HTMLAnchorElement.cpp:
(WebCore::HTMLAnchorElement::handleClick):
This change is because of clarification in naming:
- attributionReportSourceURL to attributionReportClickSourceURL
* loader/PrivateClickMeasurement.cpp:
(WebCore::PrivateClickMeasurement::parseAttributionRequestQuery):
New function that parses out query string parameters.
(WebCore::PrivateClickMeasurement::parseAttributionRequest):
Now calls the new PrivateClickMeasurement::parseAttributionRequestQuery()
which handles data coming in in query parameters, in this case
the new parameter "attributionSource."
(WebCore::PrivateClickMeasurement::attributionReportClickSourceURL const):
New name.
(WebCore::PrivateClickMeasurement::attributionReportClickDestinationURL const):
New name.
(WebCore::PrivateClickMeasurement::attributionReportJSON const):
Now uses the constant privateClickMeasurementVersion.
(WebCore::PrivateClickMeasurement::tokenSignatureJSON const):
Now uses the constant privateClickMeasurementVersion.
(WebCore::PrivateClickMeasurement::attributionReportSourceURL const): Deleted.
Renamed attributionReportClickSourceURL.
(WebCore::PrivateClickMeasurement::attributionReportAttributeOnURL const): Deleted.
Renamed attributionReportClickDestinationURL.
* loader/PrivateClickMeasurement.h:
(WebCore::PrivateClickMeasurement::sourceSecretToken const):
New name.
(WebCore::PrivateClickMeasurement::AttributionTriggerData::encode const):
(WebCore::PrivateClickMeasurement::AttributionTriggerData::decode):
Encoding and decoding of the new field sourceRegistrableDomain.
(WebCore::PrivateClickMeasurement::sourceUnlinkableToken const): Deleted.
Renamed sourceSecretToken.
Note that it was always the secret token used, just bad renaming earlier.

2021-11-17 Tim Horton <[email protected]>

Momentum animator: Short scrolls are too far, medium scrolls aren't far enough
Expand Down
2 changes: 1 addition & 1 deletion Source/WebCore/html/HTMLAnchorElement.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ void HTMLAnchorElement::handleClick(Event& event)
auto privateClickMeasurement = parsePrivateClickMeasurement();
// A matching triggering event needs to happen before an attribution report can be sent.
// Thus, URLs should be empty for now.
ASSERT(!privateClickMeasurement || (privateClickMeasurement->attributionReportSourceURL().isNull() && privateClickMeasurement->attributionReportAttributeOnURL().isNull()));
ASSERT(!privateClickMeasurement || (privateClickMeasurement->attributionReportClickSourceURL().isNull() && privateClickMeasurement->attributionReportClickDestinationURL().isNull()));

frame->loader().changeLocation(completedURL, effectiveTarget, &event, referrerPolicy, document().shouldOpenExternalURLsPolicyToPropagate(), newFrameOpenerPolicy, downloadAttribute, systemPreviewInfo, WTFMove(privateClickMeasurement));

Expand Down
54 changes: 46 additions & 8 deletions Source/WebCore/loader/PrivateClickMeasurement.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ static const char privateClickMeasurementTokenPublicKeyPath[] = "/.well-known/pr
static const char privateClickMeasurementReportAttributionPath[] = "/.well-known/private-click-measurement/report-attribution/";
const size_t privateClickMeasurementAttributionTriggerDataPathSegmentSize = 2;
const size_t privateClickMeasurementPriorityPathSegmentSize = 2;
const uint8_t privateClickMeasurementVersion = 2;

const Seconds PrivateClickMeasurement::maxAge()
{
Expand Down Expand Up @@ -103,23 +104,58 @@ PrivateClickMeasurement PrivateClickMeasurement::isolatedCopy() const
return copy;
}

Expected<PrivateClickMeasurement::AttributionTriggerData, String> PrivateClickMeasurement::parseAttributionRequestQuery(const URL& redirectURL)
{
if (!redirectURL.hasQuery())
return AttributionTriggerData { };

auto parameters = queryParameters(redirectURL);
if (!parameters.size())
return makeUnexpected("[Private Click Measurement] Conversion was not accepted because the URL had a query string but it didn't contain supported parameters."_s);

if (parameters.size() > 1)
return makeUnexpected("[Private Click Measurement] Conversion was not accepted because the URL's query string contained unsupported parameters."_s);

auto parameter = parameters.first();
if (parameter.key == "attributionSource") {
if (parameter.value.isEmpty())
return makeUnexpected("[Private Click Measurement] Conversion was not accepted because the URL's attributionSource query parameter had no value."_s);

auto attributionSourceURL = URL(URL(), parameter.value);
if (!attributionSourceURL.isValid() || (attributionSourceURL.hasPath() && attributionSourceURL.path().length() > 1) || attributionSourceURL.hasCredentials() || attributionSourceURL.hasQuery() || attributionSourceURL.hasFragmentIdentifier())
return makeUnexpected("[Private Click Measurement] Conversion was not accepted because the URL's attributionSource query parameter was not a valid URL or was a URL with a path, credentials, query string, or fragment."_s);

AttributionTriggerData attributionTriggerData;
attributionTriggerData.sourceRegistrableDomain = RegistrableDomain { attributionSourceURL };
return attributionTriggerData;
}

return makeUnexpected("[Private Click Measurement] Conversion was not accepted because the URL did not contain an attributionSource query parameter."_s);
}

Expected<PrivateClickMeasurement::AttributionTriggerData, String> PrivateClickMeasurement::parseAttributionRequest(const URL& redirectURL)
{
auto path = StringView(redirectURL.string()).substring(redirectURL.pathStart(), redirectURL.pathEnd() - redirectURL.pathStart());
if (path.isEmpty() || !path.startsWith(privateClickMeasurementTriggerAttributionPath))
return makeUnexpected(nullString());

if (!redirectURL.protocolIs("https") || redirectURL.hasCredentials() || redirectURL.hasQuery() || redirectURL.hasFragmentIdentifier())
return makeUnexpected("[Private Click Measurement] Conversion was not accepted because the URL's protocol is not HTTPS or the URL contains one or more of username, password, query string, and fragment."_s);
if (!redirectURL.protocolIs("https") || redirectURL.hasCredentials() || redirectURL.hasFragmentIdentifier())
return makeUnexpected("[Private Click Measurement] Conversion was not accepted because the URL's protocol is not HTTPS or the URL contains one or more of username, password, and fragment."_s);

auto result = parseAttributionRequestQuery(redirectURL);
if (!result && !result.error().isEmpty())
return result;
auto attributionTriggerData = result.value();

auto prefixLength = sizeof(privateClickMeasurementTriggerAttributionPath) - 1;
if (path.length() == prefixLength + privateClickMeasurementAttributionTriggerDataPathSegmentSize) {
auto attributionTriggerDataUInt64 = parseInteger<uint64_t>(path.substring(prefixLength, privateClickMeasurementAttributionTriggerDataPathSegmentSize));
if (!attributionTriggerDataUInt64 || *attributionTriggerDataUInt64 > AttributionTriggerData::MaxEntropy)
return makeUnexpected(makeString("[Private Click Measurement] Conversion was not accepted because the conversion data could not be parsed or was higher than the allowed maximum of "_s, AttributionTriggerData::MaxEntropy, "."_s));

return AttributionTriggerData { static_cast<uint8_t>(*attributionTriggerDataUInt64), Priority { 0 } };
attributionTriggerData.data = static_cast<uint8_t>(*attributionTriggerDataUInt64);
attributionTriggerData.priority = 0;
return attributionTriggerData;
}

if (path.length() == prefixLength + privateClickMeasurementAttributionTriggerDataPathSegmentSize + 1 + privateClickMeasurementPriorityPathSegmentSize) {
Expand All @@ -131,7 +167,9 @@ Expected<PrivateClickMeasurement::AttributionTriggerData, String> PrivateClickMe
if (!attributionPriorityUInt64 || *attributionPriorityUInt64 > Priority::MaxEntropy)
return makeUnexpected(makeString("[Private Click Measurement] Conversion was not accepted because the priority could not be parsed or was higher than the allowed maximum of "_s, Priority::MaxEntropy, "."_s));

return AttributionTriggerData { static_cast<uint8_t>(*attributionTriggerDataUInt64), Priority { static_cast<uint8_t>(*attributionPriorityUInt64) } };
attributionTriggerData.data = static_cast<uint8_t>(*attributionTriggerDataUInt64);
attributionTriggerData.priority = static_cast<uint8_t>(*attributionPriorityUInt64);
return attributionTriggerData;
}

return makeUnexpected("[Private Click Measurement] Conversion was not accepted because the URL path contained unrecognized parts."_s);
Expand Down Expand Up @@ -189,15 +227,15 @@ static URL attributionReportURL(const RegistrableDomain& domain)
return makeValidURL(domain, privateClickMeasurementReportAttributionPath);
}

URL PrivateClickMeasurement::attributionReportSourceURL() const
URL PrivateClickMeasurement::attributionReportClickSourceURL() const
{
if (!isValid())
return URL();

return attributionReportURL(m_sourceSite.registrableDomain);
}

URL PrivateClickMeasurement::attributionReportAttributeOnURL() const
URL PrivateClickMeasurement::attributionReportClickDestinationURL() const
{
if (!isValid())
return URL();
Expand All @@ -216,7 +254,7 @@ Ref<JSON::Object> PrivateClickMeasurement::attributionReportJSON() const
reportDetails->setInteger("source_id"_s, m_sourceID.id);
reportDetails->setString("attributed_on_site"_s, m_destinationSite.registrableDomain.string());
reportDetails->setInteger("trigger_data"_s, m_attributionTriggerData->data);
reportDetails->setInteger("version"_s, 2);
reportDetails->setInteger("version"_s, privateClickMeasurementVersion);

// This token has been kept secret this far and cannot be linked to the unlinkable token.
if (m_sourceSecretToken) {
Expand Down Expand Up @@ -274,7 +312,7 @@ Ref<JSON::Object> PrivateClickMeasurement::tokenSignatureJSON() const
reportDetails->setString("source_nonce"_s, m_ephemeralSourceNonce->nonce);
// This token can not be linked to the secret token.
reportDetails->setString("source_unlinkable_token"_s, m_sourceUnlinkableToken.valueBase64URL);
reportDetails->setInteger("version"_s, 2);
reportDetails->setInteger("version"_s, privateClickMeasurementVersion);
return reportDetails;
}

Expand Down
Loading

0 comments on commit c1c5ecb

Please sign in to comment.