1/*
2 * Copyright (C) 2019 Apple Inc. All rights reserved.
3 *
4 * Redistribution and use in source and binary forms, with or without
5 * modification, are permitted provided that the following conditions
6 * are met:
7 * 1. Redistributions of source code must retain the above copyright
8 * notice, this list of conditions and the following disclaimer.
9 * 2. Redistributions in binary form must reproduce the above copyright
10 * notice, this list of conditions and the following disclaimer in the
11 * documentation and/or other materials provided with the distribution.
12 *
13 * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
14 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
15 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
16 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
17 * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
23 * THE POSSIBILITY OF SUCH DAMAGE.
24 */
25
26#include "config.h"
27#include "AdClickAttribution.h"
28
29#include "Logging.h"
30#include "RuntimeEnabledFeatures.h"
31#include <wtf/RandomNumber.h>
32#include <wtf/URL.h>
33#include <wtf/text/StringBuilder.h>
34#include <wtf/text/StringView.h>
35
36namespace WebCore {
37
38static const char adClickAttributionPathPrefix[] = "/.well-known/ad-click-attribution/";
39const size_t adClickConversionDataPathSegmentSize = 2;
40const size_t adClickPriorityPathSegmentSize = 2;
41const Seconds maxAge { 24_h * 7 };
42
43bool AdClickAttribution::isValid() const
44{
45 return m_conversion
46 && m_conversion.value().isValid()
47 && m_campaign.isValid()
48 && !m_source.registrableDomain.isEmpty()
49 && !m_destination.registrableDomain.isEmpty()
50 && m_earliestTimeToSend;
51}
52
53Optional<AdClickAttribution::Conversion> AdClickAttribution::parseConversionRequest(const URL& redirectURL)
54{
55 if (!redirectURL.protocolIs("https"_s) || redirectURL.hasUsername() || redirectURL.hasPassword() || redirectURL.hasQuery() || redirectURL.hasFragment()) {
56 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "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.");
57 return { };
58 }
59
60 auto path = StringView(redirectURL.string()).substring(redirectURL.pathStart(), redirectURL.pathEnd() - redirectURL.pathStart());
61 if (path.isEmpty() || !path.startsWith(adClickAttributionPathPrefix)) {
62 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "Conversion was not accepted because the URL path did not start with %{public}s.", adClickAttributionPathPrefix);
63 return { };
64 }
65
66 auto prefixLength = sizeof(adClickAttributionPathPrefix) - 1;
67 if (path.length() == prefixLength + adClickConversionDataPathSegmentSize) {
68 auto conversionDataUInt64 = path.substring(prefixLength, adClickConversionDataPathSegmentSize).toUInt64Strict();
69 if (!conversionDataUInt64 || *conversionDataUInt64 > MaxEntropy) {
70 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "Conversion was not accepted because the conversion data could not be parsed or was higher than the allowed maximum of %{public}u.", MaxEntropy);
71 return { };
72 }
73
74 return Conversion { static_cast<uint32_t>(*conversionDataUInt64), Priority { 0 } };
75 }
76
77 if (path.length() == prefixLength + adClickConversionDataPathSegmentSize + 1 + adClickPriorityPathSegmentSize) {
78 auto conversionDataUInt64 = path.substring(prefixLength, adClickConversionDataPathSegmentSize).toUInt64Strict();
79 if (!conversionDataUInt64 || *conversionDataUInt64 > MaxEntropy) {
80 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "Conversion was not accepted because the conversion data could not be parsed or was higher than the allowed maximum of %{public}u.", MaxEntropy);
81 return { };
82 }
83
84 auto conversionPriorityUInt64 = path.substring(prefixLength + adClickConversionDataPathSegmentSize + 1, adClickPriorityPathSegmentSize).toUInt64Strict();
85 if (!conversionPriorityUInt64 || *conversionPriorityUInt64 > MaxEntropy) {
86 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "Conversion was not accepted because the priority could not be parsed or was higher than the allowed maximum of %{public}u.", MaxEntropy);
87 return { };
88 }
89
90 return Conversion { static_cast<uint32_t>(*conversionDataUInt64), Priority { static_cast<uint32_t>(*conversionPriorityUInt64) } };
91 }
92
93 RELEASE_LOG_INFO_IF(debugModeEnabled(), AdClickAttribution, "Conversion was not accepted because the URL path contained unrecognized parts.");
94 return { };
95}
96
97Optional<Seconds> AdClickAttribution::convertAndGetEarliestTimeToSend(Conversion&& conversion)
98{
99 if (!conversion.isValid() || (m_conversion && m_conversion->priority >= conversion.priority))
100 return { };
101
102 m_conversion = WTFMove(conversion);
103 // 24-48 hour delay before sending. This helps privacy since the conversion and the attribution
104 // requests are detached and the time of the attribution does not reveal the time of the conversion.
105 auto seconds = 24_h + Seconds(randomNumber() * (24_h).value());
106 m_earliestTimeToSend = WallTime::now() + seconds;
107 return seconds;
108}
109
110void AdClickAttribution::markAsExpired()
111{
112 m_timeOfAdClick = { };
113}
114
115bool AdClickAttribution::hasExpired() const
116{
117 return WallTime::now() > m_timeOfAdClick + maxAge;
118}
119
120bool AdClickAttribution::hasHigherPriorityThan(const AdClickAttribution& other) const
121{
122 if (!other.m_conversion)
123 return true;
124
125 if (!m_conversion)
126 return false;
127
128 return m_conversion->priority > other.m_conversion->priority;
129}
130
131URL AdClickAttribution::url() const
132{
133 if (!isValid())
134 return URL();
135
136 StringBuilder builder;
137 builder.appendLiteral("https://");
138 builder.append(m_source.registrableDomain.string());
139 builder.appendLiteral(adClickAttributionPathPrefix);
140 builder.appendNumber(m_conversion.value().data);
141 builder.append('/');
142 builder.appendNumber(m_campaign.id);
143
144 URL url { URL(), builder.toString() };
145 if (url.isValid())
146 return url;
147
148 return URL();
149}
150
151URL AdClickAttribution::referrer() const
152{
153 if (!isValid())
154 return URL();
155
156 StringBuilder builder;
157 builder.appendLiteral("https://");
158 builder.append(m_destination.registrableDomain.string());
159 builder.append('/');
160
161 URL url { URL(), builder.toString() };
162 if (url.isValid())
163 return url;
164
165 return URL();
166}
167
168URL AdClickAttribution::urlForTesting(const URL& baseURL) const
169{
170 auto host = m_source.registrableDomain.string();
171 if (host != "localhost" && host != "127.0.0.1")
172 return URL();
173
174 StringBuilder builder;
175 builder.appendLiteral("?conversion=");
176 builder.appendNumber(m_conversion.value().data);
177 builder.appendLiteral("&campaign=");
178 builder.appendNumber(m_campaign.id);
179 if (baseURL.hasQuery()) {
180 builder.append('&');
181 builder.append(baseURL.query());
182 }
183 return URL(baseURL, builder.toString());
184}
185
186void AdClickAttribution::markConversionAsSent()
187{
188 ASSERT(m_conversion);
189 if (m_conversion)
190 m_conversion->wasSent = Conversion::WasSent::Yes;
191}
192
193bool AdClickAttribution::wasConversionSent() const
194{
195 return m_conversion && m_conversion->wasSent == Conversion::WasSent::Yes;
196}
197
198String AdClickAttribution::toString() const
199{
200 StringBuilder builder;
201 builder.appendLiteral("Source: ");
202 builder.append(m_source.registrableDomain.string());
203 builder.appendLiteral("\nDestination: ");
204 builder.append(m_destination.registrableDomain.string());
205 builder.appendLiteral("\nCampaign ID: ");
206 builder.appendNumber(m_campaign.id);
207 if (m_conversion) {
208 builder.appendLiteral("\nConversion data: ");
209 builder.appendNumber(m_conversion.value().data);
210 builder.appendLiteral("\nConversion priority: ");
211 builder.appendNumber(m_conversion.value().priority);
212 builder.appendLiteral("\nConversion earliest time to send: ");
213 if (!m_earliestTimeToSend)
214 builder.appendLiteral("Not set");
215 else {
216 auto secondsUntilSend = *m_earliestTimeToSend - WallTime::now();
217 builder.append((secondsUntilSend >= 24_h && secondsUntilSend <= 48_h) ? "Within 24-48 hours" : "Outside 24-48 hours");
218 }
219 builder.appendLiteral("\nConversion request sent: ");
220 builder.append((wasConversionSent() ? "true" : "false"));
221 } else
222 builder.appendLiteral("\nNo conversion data.");
223 builder.append('\n');
224
225 return builder.toString();
226}
227
228bool AdClickAttribution::debugModeEnabled()
229{
230 return RuntimeEnabledFeatures::sharedFeatures().adClickAttributionDebugModeEnabled();
231}
232
233}
234