Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ public static FetchItem create(URL u, String url, Tuple t, String queueMode) {
key = u.getHost();
}
} else {
key = u.getHost();
key = URLUtil.getCanonicalHost(u);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the byHost branch is canonicalised. byIP above still calls InetAddress.getByName(u.getHost()) and byDomain still calls PaidLevelDomain.getPLD(u.getHost()) on the raw escaped host, so http://%65xample.org/ still gets a separate queue from http://example.org/ in those modes.

Compute it once before the if and use it in all three branches:

final String canonicalHost = URLUtil.getCanonicalHost(u);
if (FetchItemQueues.QUEUE_MODE_IP.equalsIgnoreCase(queueMode)) {
    ... InetAddress.getByName(canonicalHost) ...
} else if (FetchItemQueues.QUEUE_MODE_DOMAIN.equalsIgnoreCase(queueMode)) {
    key = PaidLevelDomain.getPLD(canonicalHost);
    ...
} else {
    key = canonicalHost;
}

}

if (key == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ private String getPolitenessKey(URL u) {
key = u.getHost();
}
} else {
key = u.getHost();
key = URLUtil.getCanonicalHost(u);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as FetcherBolt: byIP (line 615) and byDomain (line 625) still use the raw u.getHost(), so those two modes keep the behaviour the issue is about. Canonicalise once at the top of the method and use it in all three branches.

if (key == null) {
LOG.warn("Unknown host for url: {}, using URL string as key", u.toExternalForm());
key = u.toExternalForm();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ private static void logForwardedRequestHeaders(Config conf) {
/** Compose unique key to store and access robot rules in cache for given URL. */
protected static String getCacheKey(URL url) {
String protocol = url.getProtocol().toLowerCase(Locale.ROOT);
String host = url.getHost().toLowerCase(Locale.ROOT);
// canonicalise the host so aliases of one server (percent-escaping,
// case, trailing dot) share one cache entry and one robots.txt fetch
String host = URLUtil.getCanonicalHost(url);
if (host == null) {
host = "";
}

int port = url.getPort();
if (port == -1) {
Expand Down
26 changes: 26 additions & 0 deletions core/src/main/java/org/apache/stormcrawler/util/URLUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.regex.Matcher;
Expand Down Expand Up @@ -253,6 +254,31 @@ public static String getHost(String url) {
}
}

/**
* Returns the host in the form the HTTP client will connect to it: percent-escapes decoded,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says "the host in the form the HTTP client will connect to it", but HostAliasCacheKeyTest.okhttpCollapsesHostAliases in this same PR asserts that okhttp keeps the trailing dot (example.org.), which this method strips.

Keep the behaviour, it is the better politeness key. Reword to something like "the form used to key politeness queues and the robots.txt cache: what okhttp connects to, with the root label normalised away".

* lowercased and without a trailing dot. Host strings which only differ in escaping or case
* reach the same server, so politeness queues and robots.txt caches must key on the same
* value, otherwise one server is fetched under several queue ids and its robots.txt is
* downloaded once per spelling.
*
* @param url The url to check.
* @return String The canonical host for the url, or null if the url is not well formed or has
* no host.
*/
public static String getCanonicalHost(URL url) {
String host = url.getHost();
if (host == null) {
return null;
}
// okhttp percent-decodes the host when it parses the URL; do the same
// so keys derived from the URL string agree with what it connects to
String decoded = URLDecoder.decode(host, StandardCharsets.UTF_8);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URLDecoder.decode throws on a malformed escape and maps + to a space. Both are reachable from crawled content, which the security model treats as hostile. Verified on JDK 25:

new URL("http://exa%zz.org/").getHost()  ->  "exa%zz.org"
URLDecoder.decode("exa%zz.org", UTF_8)   ->  IllegalArgumentException
URLDecoder.decode("a+b.example.org")     ->  "a b.example.org"

The exception propagates out of HttpRobotRulesParser.getCacheKey and FetchItem.create, both of which take a URL from a fetched page.

Suggested change
String decoded = URLDecoder.decode(host, StandardCharsets.UTF_8);
String decoded;
try {
decoded = new URI(url.getProtocol(), null, host, -1, "/", null, null).getHost();
} catch (URISyntaxException e) {
decoded = host;
}
if (decoded == null) {
decoded = host;
}

A hand-rolled percent-decoder that leaves + alone and falls back to the raw host also works. The requirement is that it never throws.

if (decoded.endsWith(".")) {
decoded = decoded.substring(0, decoded.length() - 1);
}
return decoded.toLowerCase(Locale.ROOT);
}

/**
* Returns the page for the url. The page consists of the protocol, host, and path, but does not
* include the query string. The host is lowercased but the path is not.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to you under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.apache.stormcrawler.protocol;

import java.net.URL;
import okhttp3.HttpUrl;
import org.apache.stormcrawler.util.URLUtil;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

/**
* Two URLs whose host strings differ only by percent-escaping or by a trailing dot are sent to the
* same server by okhttp, so they must share one robots.txt cache entry and one politeness queue.
*/
class HostAliasCacheKeyTest {

@Test
void okhttpCollapsesHostAliases() {
// what the client actually connects to; okhttp percent-decodes and
// lowercases the host but keeps a trailing dot (as the JDK does)
Assertions.assertEquals(
"example.org", HttpUrl.parse("http://%65xample.org/a").host(), "percent-escaped");
Assertions.assertEquals(
"example.org", HttpUrl.parse("http://exampl%65.org/a").host(), "percent-escaped");
Assertions.assertEquals(
"example.org", HttpUrl.parse("http://EXAMPLE.org/a").host(), "upper case");
Assertions.assertEquals(
"example.org.", HttpUrl.parse("http://example.org./a").host(), "trailing dot");
}

@Test
void canonicalHostMatchesWhatOkHttpConnectsTo() throws Exception {
Assertions.assertEquals(
HttpUrl.parse("http://exampl%65.org/a").host(),
URLUtil.getCanonicalHost(new URL("http://exampl%65.org/a")));
Assertions.assertEquals(
"example.org", URLUtil.getCanonicalHost(new URL("http://example.org./a")));
Assertions.assertEquals(
"example.org", URLUtil.getCanonicalHost(new URL("http://EXAMPLE.org/a")));
}

@Test
void robotsCacheKeyIsTheSameForHostAliases() throws Exception {
String canonical = HttpRobotRulesParser.getCacheKey(new URL("http://example.org/a"));
Assertions.assertEquals(
canonical, HttpRobotRulesParser.getCacheKey(new URL("http://exampl%65.org/a")));
Assertions.assertEquals(
canonical, HttpRobotRulesParser.getCacheKey(new URL("http://example.org./a")));
Assertions.assertEquals(
canonical, HttpRobotRulesParser.getCacheKey(new URL("http://EXAMPLE.org/a")));
}
}
Loading