/src/qtbase/src/network/access/qhsts.cpp
Line | Count | Source |
1 | | // Copyright (C) 2017 The Qt Company Ltd. |
2 | | // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only |
3 | | // Qt-Security score:critical reason:data-parser |
4 | | |
5 | | #include "qhsts_p.h" |
6 | | |
7 | | #include "qhttpheaders.h" |
8 | | |
9 | | #include "QtCore/private/qipaddress_p.h" |
10 | | #include "QtCore/qlist.h" |
11 | | |
12 | | #if QT_CONFIG(settings) |
13 | | #include "qhstsstore_p.h" |
14 | | #endif // QT_CONFIG(settings) |
15 | | |
16 | | QT_BEGIN_NAMESPACE |
17 | | |
18 | | static bool is_valid_domain_name(const QString &host) |
19 | 0 | { |
20 | 0 | if (!host.size()) |
21 | 0 | return false; |
22 | | |
23 | | // RFC6797 8.1.1 |
24 | | // If the substring matching the host production from the Request-URI |
25 | | // (of the message to which the host responded) syntactically matches |
26 | | //the IP-literal or IPv4address productions from Section 3.2.2 of |
27 | | //[RFC3986], then the UA MUST NOT note this host as a Known HSTS Host. |
28 | 0 | using namespace QIPAddressUtils; |
29 | |
|
30 | 0 | IPv4Address ipv4Addr = {}; |
31 | 0 | if (parseIp4(ipv4Addr, host.constBegin(), host.constEnd())) |
32 | 0 | return false; |
33 | | |
34 | 0 | IPv6Address ipv6Addr = {}; |
35 | | // Unlike parseIp4, parseIp6 returns nullptr if it managed to parse IPv6 |
36 | | // address successfully. |
37 | 0 | if (!parseIp6(ipv6Addr, host.constBegin(), host.constEnd())) |
38 | 0 | return false; |
39 | | |
40 | | // TODO: for now we do not test IPvFuture address, it must be addressed |
41 | | // by introducing parseIpFuture (actually, there is an implementation |
42 | | // in QUrl that can be adopted/modified/moved to QIPAddressUtils). |
43 | 0 | return true; |
44 | 0 | } |
45 | | |
46 | | void QHstsCache::updateFromHeaders(const QHttpHeaders &headers, |
47 | | const QUrl &url) |
48 | 0 | { |
49 | 0 | if (!url.isValid()) |
50 | 0 | return; |
51 | | |
52 | 0 | QHstsHeaderParser parser; |
53 | 0 | if (parser.parse(headers)) { |
54 | 0 | updateKnownHost(url.host(), parser.expirationDate(), parser.includeSubDomains()); |
55 | 0 | #if QT_CONFIG(settings) |
56 | 0 | if (hstsStore) |
57 | 0 | hstsStore->synchronize(); |
58 | 0 | #endif // QT_CONFIG(settings) |
59 | 0 | } |
60 | 0 | } |
61 | | |
62 | | void QHstsCache::updateFromPolicies(const QList<QHstsPolicy> &policies) |
63 | 0 | { |
64 | 0 | for (const auto &policy : policies) |
65 | 0 | updateKnownHost(policy.host(), policy.expiry(), policy.includesSubDomains()); |
66 | |
|
67 | 0 | #if QT_CONFIG(settings) |
68 | 0 | if (hstsStore && policies.size()) { |
69 | | // These policies are coming either from store or from QNAM's setter |
70 | | // function. As a result we can notice expired or new policies, time |
71 | | // to sync ... |
72 | 0 | hstsStore->synchronize(); |
73 | 0 | } |
74 | 0 | #endif // QT_CONFIG(settings) |
75 | 0 | } |
76 | | |
77 | | void QHstsCache::updateKnownHost(const QUrl &url, const QDateTime &expires, |
78 | | bool includeSubDomains) |
79 | 0 | { |
80 | 0 | if (!url.isValid()) |
81 | 0 | return; |
82 | | |
83 | 0 | updateKnownHost(url.host(), expires, includeSubDomains); |
84 | 0 | #if QT_CONFIG(settings) |
85 | 0 | if (hstsStore) |
86 | 0 | hstsStore->synchronize(); |
87 | 0 | #endif // QT_CONFIG(settings) |
88 | 0 | } |
89 | | |
90 | | void QHstsCache::updateKnownHost(const QString &host, const QDateTime &expires, |
91 | | bool includeSubDomains) |
92 | 0 | { |
93 | 0 | if (!is_valid_domain_name(host)) |
94 | 0 | return; |
95 | | |
96 | | // HSTS is a per-host policy, regardless of protocol, port or any of the other |
97 | | // details in an URL; so we only want the host part. QUrl::host handles |
98 | | // IDNA 2003 (RFC3490) for us, as required by HSTS (RFC6797, section 10). |
99 | 0 | const HostName hostName(host); |
100 | 0 | const auto pos = knownHosts.find(hostName); |
101 | 0 | QHstsPolicy::PolicyFlags flags; |
102 | 0 | if (includeSubDomains) |
103 | 0 | flags = QHstsPolicy::IncludeSubDomains; |
104 | |
|
105 | 0 | const QHstsPolicy newPolicy(expires, flags, hostName.name); |
106 | 0 | if (pos == knownHosts.end()) { |
107 | | // A new, previously unknown host. |
108 | 0 | if (newPolicy.isExpired()) { |
109 | | // Nothing to do at all - we did not know this host previously, |
110 | | // we do not have to - since its policy expired. |
111 | 0 | return; |
112 | 0 | } |
113 | | |
114 | 0 | knownHosts.insert({hostName, newPolicy}); |
115 | 0 | #if QT_CONFIG(settings) |
116 | 0 | if (hstsStore) |
117 | 0 | hstsStore->addToObserved(newPolicy); |
118 | 0 | #endif // QT_CONFIG(settings) |
119 | 0 | return; |
120 | 0 | } |
121 | | |
122 | 0 | if (newPolicy.isExpired()) |
123 | 0 | knownHosts.erase(pos); |
124 | 0 | else if (pos->second != newPolicy) |
125 | 0 | pos->second = newPolicy; |
126 | 0 | else |
127 | 0 | return; |
128 | | |
129 | 0 | #if QT_CONFIG(settings) |
130 | 0 | if (hstsStore) |
131 | 0 | hstsStore->addToObserved(newPolicy); |
132 | 0 | #endif // QT_CONFIG(settings) |
133 | 0 | } |
134 | | |
135 | | bool QHstsCache::isKnownHost(const QUrl &url) const |
136 | 0 | { |
137 | 0 | if (!url.isValid() || !is_valid_domain_name(url.host())) |
138 | 0 | return false; |
139 | | |
140 | | /* |
141 | | RFC6797, 8.2. Known HSTS Host Domain Name Matching |
142 | | |
143 | | * Superdomain Match |
144 | | If a label-for-label match between an entire Known HSTS Host's |
145 | | domain name and a right-hand portion of the given domain name |
146 | | is found, then this Known HSTS Host's domain name is a |
147 | | superdomain match for the given domain name. There could be |
148 | | multiple superdomain matches for a given domain name. |
149 | | * Congruent Match |
150 | | If a label-for-label match between a Known HSTS Host's domain |
151 | | name and the given domain name is found -- i.e., there are no |
152 | | further labels to compare -- then the given domain name |
153 | | congruently matches this Known HSTS Host. |
154 | | |
155 | | We start from the congruent match, and then chop labels and dots and |
156 | | proceed with superdomain match. While RFC6797 recommends to start from |
157 | | superdomain, the result is the same - some valid policy will make a host |
158 | | known. |
159 | | */ |
160 | | |
161 | 0 | bool superDomainMatch = false; |
162 | 0 | const QString hostNameAsString(url.host()); |
163 | 0 | HostName nameToTest(QStringView{hostNameAsString}); |
164 | 0 | while (nameToTest.fragment.size()) { |
165 | 0 | auto const pos = knownHosts.find(nameToTest); |
166 | 0 | if (pos != knownHosts.end()) { |
167 | 0 | if (pos->second.isExpired()) { |
168 | 0 | knownHosts.erase(pos); |
169 | 0 | #if QT_CONFIG(settings) |
170 | 0 | if (hstsStore) { |
171 | | // Inform our store that this policy has expired. |
172 | 0 | hstsStore->addToObserved(pos->second); |
173 | 0 | } |
174 | 0 | #endif // QT_CONFIG(settings) |
175 | 0 | } else if (!superDomainMatch || pos->second.includesSubDomains()) { |
176 | 0 | return true; |
177 | 0 | } |
178 | 0 | } |
179 | | |
180 | 0 | const qsizetype dot = nameToTest.fragment.indexOf(u'.'); |
181 | 0 | if (dot == -1) |
182 | 0 | break; |
183 | | |
184 | 0 | nameToTest.fragment = nameToTest.fragment.mid(dot + 1); |
185 | 0 | superDomainMatch = true; |
186 | 0 | } |
187 | | |
188 | 0 | return false; |
189 | 0 | } |
190 | | |
191 | | void QHstsCache::clear() |
192 | 0 | { |
193 | 0 | knownHosts.clear(); |
194 | 0 | } |
195 | | |
196 | | QList<QHstsPolicy> QHstsCache::policies() const |
197 | 0 | { |
198 | 0 | QList<QHstsPolicy> values; |
199 | 0 | values.reserve(int(knownHosts.size())); |
200 | 0 | for (const auto &host : knownHosts) |
201 | 0 | values << host.second; |
202 | 0 | return values; |
203 | 0 | } |
204 | | |
205 | | #if QT_CONFIG(settings) |
206 | | void QHstsCache::setStore(QHstsStore *store) |
207 | 0 | { |
208 | | // Caller retains ownership of store, which must outlive this cache. |
209 | 0 | if (store != hstsStore) { |
210 | 0 | hstsStore = store; |
211 | |
|
212 | 0 | if (!hstsStore) |
213 | 0 | return; |
214 | | |
215 | | // First we augment our store with the policies we already know about |
216 | | // (and thus the cached policy takes priority over whatever policy we |
217 | | // had in the store for the same host, if any). |
218 | 0 | if (knownHosts.size()) { |
219 | 0 | const QList<QHstsPolicy> observed(policies()); |
220 | 0 | for (const auto &policy : observed) |
221 | 0 | hstsStore->addToObserved(policy); |
222 | 0 | hstsStore->synchronize(); |
223 | 0 | } |
224 | | |
225 | | // Now we update the cache with anything we have not observed yet, but |
226 | | // the store knows about (well, it can happen we synchronize again as a |
227 | | // result if some policies managed to expire or if we add a new one |
228 | | // from the store to cache): |
229 | 0 | const QList<QHstsPolicy> restored(store->readPolicies()); |
230 | 0 | updateFromPolicies(restored); |
231 | 0 | } |
232 | 0 | } |
233 | | #endif // QT_CONFIG(settings) |
234 | | |
235 | | // The parser is quite simple: 'nextToken' knowns exactly what kind of tokens |
236 | | // are valid and it will return false if something else was found; then |
237 | | // we immediately stop parsing. 'parseDirective' knows how these tokens can |
238 | | // be combined into a valid directive and if some weird combination of |
239 | | // valid tokens is found - we immediately stop. |
240 | | // And finally we call parseDirective again and again until some error found or |
241 | | // we have no more bytes in the header. |
242 | | |
243 | | // The following isXXX functions are based on RFC2616, 2.2 Basic Rules. |
244 | | |
245 | | static bool isCHAR(int c) |
246 | 0 | { |
247 | | // CHAR = <any US-ASCII character (octets 0 - 127)> |
248 | 0 | return c >= 0 && c <= 127; |
249 | 0 | } |
250 | | |
251 | | static bool isCTL(int c) |
252 | 0 | { |
253 | | // CTL = <any US-ASCII control character |
254 | | // (octets 0 - 31) and DEL (127)> |
255 | 0 | return (c >= 0 && c <= 31) || c == 127; |
256 | 0 | } |
257 | | |
258 | | |
259 | | static bool isLWS(int c) |
260 | 0 | { |
261 | | // LWS = [CRLF] 1*( SP | HT ) |
262 | | // |
263 | | // CRLF = CR LF |
264 | | // CR = <US-ASCII CR, carriage return (13)> |
265 | | // LF = <US-ASCII LF, linefeed (10)> |
266 | | // SP = <US-ASCII SP, space (32)> |
267 | | // HT = <US-ASCII HT, horizontal-tab (9)> |
268 | | // |
269 | | // CRLF is handled by the time we parse a header (they were replaced with |
270 | | // spaces). We only have to deal with remaining SP|HT |
271 | 0 | return c == ' ' || c == '\t'; |
272 | 0 | } |
273 | | |
274 | | static bool isTEXT(char c) |
275 | 0 | { |
276 | | // TEXT = <any OCTET except CTLs, |
277 | | // but including LWS> |
278 | 0 | return !isCTL(c) || isLWS(c); |
279 | 0 | } |
280 | | |
281 | | static bool isSeparator(char c) |
282 | 0 | { |
283 | | // separators = "(" | ")" | "<" | ">" | "@" |
284 | | // | "," | ";" | ":" | "\" | <"> |
285 | | // | "/" | "[" | "]" | "?" | "=" |
286 | | // | "{" | "}" | SP | HT |
287 | 0 | static const char separators[] = "()<>@,;:\\\"/[]?={}"; |
288 | 0 | static const char *end = separators + sizeof separators - 1; |
289 | 0 | return isLWS(c) || std::find(separators, end, c) != end; |
290 | 0 | } |
291 | | |
292 | | static QByteArrayView unescapeMaxAge(QByteArrayView value) |
293 | 0 | { |
294 | 0 | if (value.size() < 2 || value[0] != '"') |
295 | 0 | return value; |
296 | | |
297 | 0 | Q_ASSERT(value[value.size() - 1] == '"'); |
298 | 0 | return value.mid(1, value.size() - 2); |
299 | 0 | } |
300 | | |
301 | | static bool isTOKEN(char c) |
302 | 0 | { |
303 | | // token = 1*<any CHAR except CTLs or separators> |
304 | 0 | return isCHAR(c) && !isCTL(c) && !isSeparator(c); |
305 | 0 | } |
306 | | |
307 | | /* |
308 | | |
309 | | RFC6797, 6.1 Strict-Transport-Security HTTP Response Header Field. |
310 | | Syntax: |
311 | | |
312 | | Strict-Tranposrt-Security = "Strict-Transport-Security" ":" |
313 | | [ directive ] *( ";" [ directive ] ) |
314 | | |
315 | | directive = directive-name [ "=" directive-value ] |
316 | | directive-name = token |
317 | | directive-value = token | quoted-string |
318 | | |
319 | | RFC 2616, 2.2 Basic Rules. |
320 | | |
321 | | token = 1*<any CHAR except CTLs or separators> |
322 | | quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) |
323 | | |
324 | | |
325 | | qdtext = <any TEXT except <">> |
326 | | quoted-pair = "\" CHAR |
327 | | |
328 | | */ |
329 | | |
330 | | bool QHstsHeaderParser::parse(const QHttpHeaders &headers) |
331 | 0 | { |
332 | 0 | const auto stsHeaders = headers.values(QHttpHeaders::WellKnownHeader::StrictTransportSecurity); |
333 | 0 | for (const auto &value : stsHeaders) { |
334 | 0 | header = value; |
335 | | // RFC6797, 8.1: |
336 | | // |
337 | | // The UA MUST ignore any STS header fields not conforming to the |
338 | | // grammar specified in Section 6.1 ("Strict-Transport-Security HTTP |
339 | | // Response Header Field"). |
340 | | // |
341 | | // If a UA receives more than one STS header field in an HTTP |
342 | | // response message over secure transport, then the UA MUST process |
343 | | // only the first such header field. |
344 | | // |
345 | | // We read this as: ignore all invalid headers and take the first valid: |
346 | 0 | if (parseSTSHeader() && maxAgeFound) { |
347 | 0 | expiry = QDateTime::currentDateTimeUtc().addSecs(maxAge); |
348 | 0 | return true; |
349 | 0 | } |
350 | 0 | } |
351 | | |
352 | | // In case it was set by a syntactically correct header (but without |
353 | | // REQUIRED max-age directive): |
354 | 0 | subDomainsFound = false; |
355 | |
|
356 | 0 | return false; |
357 | 0 | } |
358 | | |
359 | | bool QHstsHeaderParser::parseSTSHeader() |
360 | 0 | { |
361 | 0 | expiry = QDateTime(); |
362 | 0 | maxAgeFound = false; |
363 | 0 | subDomainsFound = false; |
364 | 0 | maxAge = 0; |
365 | 0 | tokenPos = 0; |
366 | 0 | token.clear(); |
367 | |
|
368 | 0 | while (tokenPos < header.size()) { |
369 | 0 | if (!parseDirective()) |
370 | 0 | return false; |
371 | | |
372 | 0 | if (token.size() && token != ";") { |
373 | | // After a directive we can only have a ";" or no more tokens. |
374 | | // Invalid syntax. |
375 | 0 | return false; |
376 | 0 | } |
377 | 0 | } |
378 | | |
379 | 0 | return true; |
380 | 0 | } |
381 | | |
382 | | bool QHstsHeaderParser::parseDirective() |
383 | 0 | { |
384 | | // RFC 6797, 6.1: |
385 | | // |
386 | | // directive = directive-name [ "=" directive-value ] |
387 | | // directive-name = token |
388 | | // directive-value = token | quoted-string |
389 | | |
390 | | |
391 | | // RFC 2616, 2.2: |
392 | | // |
393 | | // token = 1*<any CHAR except CTLs or separators> |
394 | |
|
395 | 0 | if (!nextToken()) |
396 | 0 | return false; |
397 | | |
398 | 0 | if (!token.size()) // No more data, but no error. |
399 | 0 | return true; |
400 | | |
401 | 0 | if (token == ";") // That's a weird grammar, but that's what it is. |
402 | 0 | return true; |
403 | | |
404 | 0 | if (!isTOKEN(token.at(0))) // Not a valid directive-name. |
405 | 0 | return false; |
406 | | |
407 | 0 | const QByteArray directiveName = token; |
408 | | // 2. Try to read "=" or ";". |
409 | 0 | if (!nextToken()) |
410 | 0 | return false; |
411 | | |
412 | 0 | QByteArray directiveValue; |
413 | 0 | if (token == ";") // No directive-value |
414 | 0 | return processDirective(directiveName, directiveValue); |
415 | | |
416 | 0 | if (token == "=") { |
417 | | // We expect a directive-value now: |
418 | 0 | if (!nextToken() || !token.size()) |
419 | 0 | return false; |
420 | 0 | directiveValue = token; |
421 | 0 | } else if (token.size()) { |
422 | | // Invalid syntax: |
423 | 0 | return false; |
424 | 0 | } |
425 | | |
426 | 0 | if (!processDirective(directiveName, directiveValue)) |
427 | 0 | return false; |
428 | | |
429 | | // Read either ";", or 'end of header', or some invalid token. |
430 | 0 | return nextToken(); |
431 | 0 | } |
432 | | |
433 | | bool QHstsHeaderParser::processDirective(const QByteArray &name, const QByteArray &value) |
434 | 0 | { |
435 | 0 | Q_ASSERT(name.size()); |
436 | | // RFC6797 6.1/3 Directive names are case-insensitive |
437 | 0 | if (name.compare("max-age", Qt::CaseInsensitive) == 0) { |
438 | | // RFC 6797, 6.1.1 |
439 | | // The syntax of the max-age directive's REQUIRED value (after |
440 | | // quoted-string unescaping, if necessary) is defined as: |
441 | | // |
442 | | // max-age-value = delta-seconds |
443 | 0 | if (maxAgeFound) { |
444 | | // RFC 6797, 6.1/2: |
445 | | // All directives MUST appear only once in an STS header field. |
446 | 0 | return false; |
447 | 0 | } |
448 | | |
449 | 0 | const QByteArrayView unescapedValue = unescapeMaxAge(value); |
450 | 0 | if (!unescapedValue.size()) |
451 | 0 | return false; |
452 | | |
453 | 0 | bool ok = false; |
454 | 0 | const qint64 age = unescapedValue.toLongLong(&ok); |
455 | 0 | if (!ok || age < 0) |
456 | 0 | return false; |
457 | | |
458 | 0 | maxAge = age; |
459 | 0 | maxAgeFound = true; |
460 | 0 | } else if (name.compare("includesubdomains", Qt::CaseInsensitive) == 0) { |
461 | | // RFC 6797, 6.1.2. The includeSubDomains Directive. |
462 | | // The OPTIONAL "includeSubDomains" directive is a valueless directive. |
463 | |
|
464 | 0 | if (subDomainsFound) { |
465 | | // RFC 6797, 6.1/2: |
466 | | // All directives MUST appear only once in an STS header field. |
467 | 0 | return false; |
468 | 0 | } |
469 | | |
470 | 0 | subDomainsFound = true; |
471 | 0 | } // else we do nothing, skip unknown directives (RFC 6797, 6.1/5) |
472 | | |
473 | 0 | return true; |
474 | 0 | } |
475 | | |
476 | | bool QHstsHeaderParser::nextToken() |
477 | 0 | { |
478 | | // Returns true if we found a valid token or we have no more data (token is |
479 | | // empty then). |
480 | |
|
481 | 0 | token.clear(); |
482 | | |
483 | | // Fortunately enough, by this point qhttpnetworkreply already got rid of |
484 | | // [CRLF] parts, but we can have 1*(SP|HT) yet. |
485 | 0 | while (tokenPos < header.size() && isLWS(header.at(tokenPos))) |
486 | 0 | ++tokenPos; |
487 | |
|
488 | 0 | if (tokenPos == header.size()) |
489 | 0 | return true; |
490 | | |
491 | 0 | const char ch = header.at(tokenPos); |
492 | 0 | if (ch == ';' || ch == '=') { |
493 | 0 | token.append(ch); |
494 | 0 | ++tokenPos; |
495 | 0 | return true; |
496 | 0 | } |
497 | | |
498 | | // RFC 2616, 2.2. |
499 | | // |
500 | | // quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) |
501 | | // qdtext = <any TEXT except <">> |
502 | 0 | if (ch == '"') { |
503 | 0 | int last = tokenPos + 1; |
504 | 0 | while (last < header.size()) { |
505 | 0 | if (header.at(last) == '"') { |
506 | | // The end of a quoted-string. |
507 | 0 | break; |
508 | 0 | } else if (header.at(last) == '\\') { |
509 | | // quoted-pair = "\" CHAR |
510 | 0 | if (last + 1 < header.size() && isCHAR(header.at(last + 1))) |
511 | 0 | last += 2; |
512 | 0 | else |
513 | 0 | return false; |
514 | 0 | } else { |
515 | 0 | if (!isTEXT(header.at(last))) |
516 | 0 | return false; |
517 | 0 | ++last; |
518 | 0 | } |
519 | 0 | } |
520 | | |
521 | 0 | if (last >= header.size()) // no closing '"': |
522 | 0 | return false; |
523 | | |
524 | 0 | token = header.mid(tokenPos, last - tokenPos + 1); |
525 | 0 | tokenPos = last + 1; |
526 | 0 | return true; |
527 | 0 | } |
528 | | |
529 | | // RFC 2616, 2.2: |
530 | | // |
531 | | // token = 1*<any CHAR except CTLs or separators> |
532 | 0 | if (!isTOKEN(ch)) |
533 | 0 | return false; |
534 | | |
535 | 0 | int last = tokenPos + 1; |
536 | 0 | while (last < header.size() && isTOKEN(header.at(last))) |
537 | 0 | ++last; |
538 | |
|
539 | 0 | token = header.mid(tokenPos, last - tokenPos); |
540 | 0 | tokenPos = last; |
541 | |
|
542 | 0 | return true; |
543 | 0 | } |
544 | | |
545 | | QT_END_NAMESPACE |