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 | |
36 | namespace WebCore { |
37 | |
38 | static const char adClickAttributionPathPrefix[] = "/.well-known/ad-click-attribution/" ; |
39 | const size_t adClickConversionDataPathSegmentSize = 2; |
40 | const size_t adClickPriorityPathSegmentSize = 2; |
41 | const Seconds maxAge { 24_h * 7 }; |
42 | |
43 | bool 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 | |
53 | Optional<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 | |
97 | Optional<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 | |
110 | void AdClickAttribution::markAsExpired() |
111 | { |
112 | m_timeOfAdClick = { }; |
113 | } |
114 | |
115 | bool AdClickAttribution::hasExpired() const |
116 | { |
117 | return WallTime::now() > m_timeOfAdClick + maxAge; |
118 | } |
119 | |
120 | bool 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 | |
131 | URL 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 | |
151 | URL 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 | |
168 | URL 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 | |
186 | void AdClickAttribution::markConversionAsSent() |
187 | { |
188 | ASSERT(m_conversion); |
189 | if (m_conversion) |
190 | m_conversion->wasSent = Conversion::WasSent::Yes; |
191 | } |
192 | |
193 | bool AdClickAttribution::wasConversionSent() const |
194 | { |
195 | return m_conversion && m_conversion->wasSent == Conversion::WasSent::Yes; |
196 | } |
197 | |
198 | String 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 | |
228 | bool AdClickAttribution::debugModeEnabled() |
229 | { |
230 | return RuntimeEnabledFeatures::sharedFeatures().adClickAttributionDebugModeEnabled(); |
231 | } |
232 | |
233 | } |
234 | |