This is an automated email from the ASF dual-hosted git repository.

rzo1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/stormcrawler.git


The following commit(s) were added to refs/heads/main by this push:
     new 88fcf3bb Scope cookies to the host that set them (#2075)
88fcf3bb is described below

commit 88fcf3bb2c6919d79937e851ca9e13493acfdcf3
Author: Richard Zowalla <[email protected]>
AuthorDate: Sat Aug 29 13:21:56 2026 +0200

    Scope cookies to the host that set them (#2075)
    
    CookieConverter only checked the domain when the cookie carried a Domain
    attribute, so a cookie without one was sent to any target URL, a Domain
    which covers every host under it such as "com" or "co.uk" was accepted as
    a scope, and checkDomainMatchToUrl returned true when it threw. getCookies
    now takes the URL whose response set the cookies, keeps a cookie without a
    Domain attribute for that host only, and fails closed on error.
    
    A Domain attribute is normalised with IDN.toASCII and validated before it
    is used, so the unicode and punycode forms of a name are interchangeable
    and a malformed value such as "com.." is rejected rather than matching
    every host under "com". The public suffix list shipped with
    crawler-commons, its private section included, decides whether a domain
    may cover subdomains at all; a domain the list does not know about, e.g.
    an internal name under ".local", falls back to the plain suffix match so
    that unlisted domains keep working. A domain which can not own subdomains
    is ignored and the cookie is bound to the host, as RFC 6265 5.3 requires,
    so a single label intranet domain keeps its cookies while "com" no longer
    scopes anything. An address is only matched by itself, per RFC 6265 5.1.3,
    instead of covering the hosts under it as "2.3.4" covered "9.2.3.4".
    
    The protocols now record which host set the cookies. When a response
    carries Set-Cookie, okhttp and playwright write its url next to the header
    as set-cookie-origin, dropping any key of that name from the response
    first so that a response can not choose where its cookies are sent. The
    key has to travel with protocol.set-cookie for host-only cookies to be
    sent back, so it is added to the metadata.persist and metadata.transfer
    examples, and a warning naming both keys is logged once when cookies are
    present without it. The metadata.transfer example in internals.adoc named
    set-cookie instead of protocol.set-cookie and is corrected too.
    
    A Set-Cookie header whose first token has no "=" threw
    StringIndexOutOfBoundsException out of getCookies, failing the whole
    fetch, and is now skipped. Cookies dropped on the path, secure and expiry
    checks are logged like the other ones.
---
 .../protocol/AbstractHttpProtocol.java             |   7 +
 .../stormcrawler/protocol/okhttp/HttpProtocol.java |  54 ++-
 .../apache/stormcrawler/util/CookieConverter.java  | 278 ++++++++++-
 core/src/main/resources/crawler-default.yaml       |   4 +-
 .../protocol/okhttp/HttpProtocolCookieTest.java    | 204 ++++++++
 .../stormcrawler/util/CookieConverterTest.java     | 511 ++++++++++++++++++++-
 .../stormcrawler/util/MetadataTransferTest.java    |  38 ++
 docs/src/main/asciidoc/configuration.adoc          |   2 +-
 docs/src/main/asciidoc/internals.adoc              |  12 +-
 .../protocol/playwright/HttpProtocol.java          |  18 +
 10 files changed, 1087 insertions(+), 41 deletions(-)

diff --git 
a/core/src/main/java/org/apache/stormcrawler/protocol/AbstractHttpProtocol.java 
b/core/src/main/java/org/apache/stormcrawler/protocol/AbstractHttpProtocol.java
index f847d567..a2f37b70 100644
--- 
a/core/src/main/java/org/apache/stormcrawler/protocol/AbstractHttpProtocol.java
+++ 
b/core/src/main/java/org/apache/stormcrawler/protocol/AbstractHttpProtocol.java
@@ -70,6 +70,13 @@ public abstract class AbstractHttpProtocol implements 
Protocol {
 
     protected static final String RESPONSE_COOKIES_HEADER = "set-cookie";
 
+    /**
+     * Metadata key holding the url whose response carried the cookies stored 
under {@link
+     * #RESPONSE_COOKIES_HEADER}. It is written by the protocol and not taken 
from a response
+     * header, and is used to scope cookies without a domain attribute to the 
host which set them.
+     */
+    protected static final String RESPONSE_COOKIES_ORIGIN = 
"set-cookie-origin";
+
     protected static final String SET_HEADER_BY_REQUEST = "set-header";
 
     protected String protocolMetadataPrefix = "";
diff --git 
a/core/src/main/java/org/apache/stormcrawler/protocol/okhttp/HttpProtocol.java 
b/core/src/main/java/org/apache/stormcrawler/protocol/okhttp/HttpProtocol.java
index ba60d7f9..90cb4d74 100644
--- 
a/core/src/main/java/org/apache/stormcrawler/protocol/okhttp/HttpProtocol.java
+++ 
b/core/src/main/java/org/apache/stormcrawler/protocol/okhttp/HttpProtocol.java
@@ -23,6 +23,7 @@ import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.MalformedURLException;
 import java.net.Proxy;
+import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.security.cert.CertificateException;
 import java.util.ArrayList;
@@ -35,6 +36,7 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 import javax.net.ssl.HostnameVerifier;
 import javax.net.ssl.SSLContext;
 import javax.net.ssl.SSLSession;
@@ -103,6 +105,9 @@ public class HttpProtocol extends AbstractHttpProtocol {
     // track the time spent for each URL in DNS resolution
     private final Map<String, Long> DNStimes = new ConcurrentHashMap<>();
 
+    // makes sure that a missing cookie origin is reported once and not for 
every url
+    private final AtomicBoolean missingCookieOriginLogged = new 
AtomicBoolean();
+
     private OkHttpClient.Builder builder;
 
     private static final TrustManager[] trustAllCerts =
@@ -287,7 +292,8 @@ public class HttpProtocol extends AbstractHttpProtocol {
         }
         try {
             final List<Cookie> cookies =
-                    CookieConverter.getCookies(cookieStrings, 
URLUtil.toURL(url));
+                    CookieConverter.getCookies(
+                            cookieStrings, getCookieOrigin(md, url), 
URLUtil.toURL(url));
             for (Cookie c : cookies) {
                 rb.addHeader("Cookie", c.getName() + "=" + c.getValue());
             }
@@ -295,6 +301,42 @@ public class HttpProtocol extends AbstractHttpProtocol {
         }
     }
 
+    /**
+     * Returns the url whose response set the cookies, or null when it was not 
recorded, in which
+     * case cookies without a domain attribute are not sent.
+     */
+    private URL getCookieOrigin(Metadata md, String url) {
+        final String origin = md.getFirstValue(RESPONSE_COOKIES_ORIGIN, 
protocolMetadataPrefix);
+        if (StringUtils.isBlank(origin)) {
+            if (missingCookieOriginLogged.compareAndSet(false, true)) {
+                LOG.warn(
+                        "No {}{} for {}, cookies without a domain attribute 
are not sent. Add {}{}"
+                                + " to metadata.transfer and metadata.persist 
next to {}{}.",
+                        protocolMetadataPrefix,
+                        RESPONSE_COOKIES_ORIGIN,
+                        url,
+                        protocolMetadataPrefix,
+                        RESPONSE_COOKIES_ORIGIN,
+                        protocolMetadataPrefix,
+                        RESPONSE_COOKIES_HEADER);
+            } else {
+                LOG.debug("No {}{} for {}", protocolMetadataPrefix, 
RESPONSE_COOKIES_ORIGIN, url);
+            }
+            return null;
+        }
+        try {
+            return URLUtil.toURL(origin);
+        } catch (MalformedURLException e) {
+            LOG.warn(
+                    "Invalid {}{} {} for {}",
+                    protocolMetadataPrefix,
+                    RESPONSE_COOKIES_ORIGIN,
+                    origin,
+                    url);
+            return null;
+        }
+    }
+
     protected void addHeadersToRequest(Builder rb, Metadata md) {
         final String[] headerStrings = md.getValues(SET_HEADER_BY_REQUEST, 
protocolMetadataPrefix);
 
@@ -445,6 +487,16 @@ public class HttpProtocol extends AbstractHttpProtocol {
                 responsemetadata.addValue(key.toLowerCase(Locale.ROOT), value);
             }
 
+            // the Set-Cookie header does not say which host sent it: record 
the url of this
+            // response so that the cookies can be scoped to it when they are 
sent back. The
+            // key is dropped first so that a server sending a header of that 
name can not
+            // forge the origin of the cookies inherited from another page.
+            responsemetadata.remove(RESPONSE_COOKIES_ORIGIN);
+            if (responsemetadata.getFirstValue(RESPONSE_COOKIES_HEADER) != 
null) {
+                responsemetadata.setValue(
+                        RESPONSE_COOKIES_ORIGIN, 
response.request().url().toString());
+            }
+
             final MutableObject<TrimmedContentReason> trimmed =
                     new MutableObject<>(TrimmedContentReason.NOT_TRIMMED);
             final byte[] bytes = toByteArray(response.body(), pageMaxContent, 
trimmed);
diff --git 
a/core/src/main/java/org/apache/stormcrawler/util/CookieConverter.java 
b/core/src/main/java/org/apache/stormcrawler/util/CookieConverter.java
index 5592fd50..e8501b81 100644
--- a/core/src/main/java/org/apache/stormcrawler/util/CookieConverter.java
+++ b/core/src/main/java/org/apache/stormcrawler/util/CookieConverter.java
@@ -17,6 +17,8 @@
 
 package org.apache.stormcrawler.util;
 
+import crawlercommons.domains.EffectiveTldFinder;
+import java.net.IDN;
 import java.net.URL;
 import java.util.ArrayList;
 import java.util.Date;
@@ -31,15 +33,49 @@ public class CookieConverter {
     private static final org.slf4j.Logger LOG =
             org.slf4j.LoggerFactory.getLogger(CookieConverter.class);
 
+    /** Maximum length of a domain name in ASCII characters, see RFC 1034. */
+    private static final int MAX_DOMAIN_LENGTH = 253;
+
     /**
      * Get a list of cookies based on the cookies string taken from response 
header and the target
-     * url.
+     * url. As the host which set the cookies is unknown, cookies without a 
domain attribute are
+     * dropped instead of being sent to the target url.
      *
      * @param cookiesStrings the value(s) of the http header for "Cookie" in 
the http response.
      * @param targetURL the url for which we wish to pass the cookies in the 
request.
      * @return List off cookies to add to the request.
+     * @deprecated use {@link #getCookies(String[], URL, URL)} instead
      */
+    @Deprecated
     public static List<Cookie> getCookies(String[] cookiesStrings, URL 
targetURL) {
+        return getCookies(cookiesStrings, null, targetURL);
+    }
+
+    /**
+     * Get a list of cookies based on the cookies string taken from response 
header, the url which
+     * set them and the target url.
+     *
+     * <p>A cookie without a domain attribute is only returned when the target 
url has the same host
+     * as the url which set the cookie, as required by RFC 6265.
+     *
+     * <p>A cookie with a domain attribute is only returned when both urls 
match that domain.
+     * Malformed domain attributes are rejected. A domain which is itself a 
public suffix, looked up
+     * in the public suffix list shipped with crawler-commons and including 
its private section such
+     * as "github.io", or which is made of a single label, does not cover 
subdomains: the cookie is
+     * then bound to the host it was set on, as browsers do. Domains which the 
list does not know
+     * about, such as internal names under ".local" or ".internal", keep the 
plain suffix match so
+     * that they remain usable as a cookie scope. A public suffix missing from 
the list, whether
+     * because the bundled list is a snapshot or because the suffix is 
genuinely internal, therefore
+     * still behaves as a normal domain.
+     *
+     * @param cookiesStrings the value(s) of the http header for "Cookie" in 
the http response.
+     * @param originURL the url whose response set the cookies, or null when 
it is unknown. The
+     *     okhttp protocol takes it from the metadata written next to the 
cookies when a response
+     *     carries them.
+     * @param targetURL the url for which we wish to pass the cookies in the 
request.
+     * @return List off cookies to add to the request.
+     */
+    public static List<Cookie> getCookies(String[] cookiesStrings, URL 
originURL, URL targetURL) {
         ArrayList<Cookie> list = new ArrayList<>();
 
         for (String cs : cookiesStrings) {
@@ -55,6 +91,11 @@ public class CookieConverter {
             String[] tokens = cs.split(";");
 
             int equals = tokens[0].indexOf("=");
+            if (equals < 1) {
+                // no name=value pair to take: the header is not usable
+                LOG.debug("Skipping cookie: no name in {}", tokens[0]);
+                continue;
+            }
             name = tokens[0].substring(0, equals);
             value = tokens[0].substring(equals + 1);
 
@@ -77,10 +118,49 @@ public class CookieConverter {
             BasicClientCookie cookie = new BasicClientCookie(name, value);
 
             // check domain
-            if (domain != null) {
+            if (domain != null && !domain.isBlank()) {
+                final String scope = normaliseDomain(domain);
+                if (scope == null) {
+                    LOG.debug("Skipping cookie {}: malformed domain {}", name, 
domain);
+                    continue;
+                }
+
                 cookie.setDomain(domain);
 
-                if (!checkDomainMatchToUrl(domain, targetURL.getHost())) {
+                // a domain which can not own subdomains, i.e. a public suffix 
such as
+                // "com" or "co.uk" or a single label, binds the cookie to the 
host itself
+                final boolean subdomains = allowsSubdomains(scope);
+
+                if (!hostMatches(scope, targetURL.getHost(), subdomains)) {
+                    LOG.debug(
+                            "Skipping cookie {}: domain {} does not cover the 
target {}",
+                            name,
+                            domain,
+                            targetURL);
+                    continue;
+                }
+
+                // the host which set the cookie must be covered by the domain 
too
+                if (originURL != null && !hostMatches(scope, 
originURL.getHost(), subdomains)) {
+                    LOG.debug(
+                            "Skipping cookie {}: domain {} does not cover {}, 
which set it",
+                            name,
+                            domain,
+                            originURL);
+                    continue;
+                }
+            } else {
+                // host only cookie: valid for the host which set it and 
nothing else
+                if (originURL == null) {
+                    LOG.debug("Skipping cookie {}: the host which set it is 
unknown", name);
+                    continue;
+                }
+                if 
(!originURL.getHost().equalsIgnoreCase(targetURL.getHost())) {
+                    LOG.debug(
+                            "Skipping cookie {}: set by {} and not valid for 
{}",
+                            name,
+                            originURL,
+                            targetURL);
                     continue;
                 }
             }
@@ -92,6 +172,11 @@ public class CookieConverter {
                 if (!path.equals("")
                         && !path.equals("/")
                         && !targetURL.getPath().startsWith(path)) {
+                    LOG.debug(
+                            "Skipping cookie {}: path {} does not cover the 
target {}",
+                            name,
+                            path,
+                            targetURL);
                     continue;
                 }
             }
@@ -101,6 +186,7 @@ public class CookieConverter {
                 cookie.setSecure(secure);
 
                 if (!targetURL.getProtocol().equalsIgnoreCase("https")) {
+                    LOG.debug("Skipping cookie {}: secure and {} is not 
https", name, targetURL);
                     continue;
                 }
             }
@@ -114,6 +200,7 @@ public class CookieConverter {
 
                         // check that it hasn't expired?
                         if (cookie.isExpired(new Date())) {
+                            LOG.debug("Skipping cookie {}: expired on {}", 
name, expires);
                             continue;
                         }
                     }
@@ -130,33 +217,182 @@ public class CookieConverter {
     }
 
     /**
-     * Helper method to check if url matches a cookie domain.
+     * Normalises the value of a cookie domain attribute into the domain it 
scopes the cookie to.
      *
-     * @param cookieDomain the domain in the cookie
-     * @param urlHostName the host name of the url
-     * @return does the cookie match the host name
+     * @param domain the value of the domain attribute
+     * @return the lower cased domain, without the leading dot allowed by RFC 
6265 and without the
+     *     root label, or null when it is not a well formed domain name
      */
-    public static boolean checkDomainMatchToUrl(String cookieDomain, String 
urlHostName) {
-        try {
-            if (cookieDomain.startsWith(".")) {
-                cookieDomain = cookieDomain.substring(1);
+    private static String normaliseDomain(String domain) {
+        String d = domain.trim().toLowerCase(Locale.ROOT);
+        // RFC 6265 5.2.3 allows exactly one leading dot
+        if (d.startsWith(".")) {
+            d = d.substring(1);
+        }
+        d = toAscii(stripRootDot(d));
+        if (d == null || d.length() > MAX_DOMAIN_LENGTH) {
+            return null;
+        }
+        for (String label : d.split("\\.", -1)) {
+            if (!isValidLabel(label)) {
+                return null;
             }
-            String[] domainTokens = cookieDomain.split("\\.");
-            String[] hostTokens = urlHostName.split("\\.");
+        }
+        return d;
+    }
+
+    /**
+     * Converts a name to the ascii form in which the public suffix list and 
the hosts are compared,
+     * so that a domain attribute written in unicode, e.g. "münchen.de", 
scopes its cookie to
+     * "xn--mnchen-3ya.de" and the two forms are interchangeable.
+     *
+     * @param name a lower cased name, without its root label
+     * @return the ascii name, or null when it is not a well formed domain 
name, e.g. "com.."
+     */
+    private static String toAscii(String name) {
+        if (name.isEmpty()) {
+            return null;
+        }
+        try {
+            // rejects the empty labels which the default split drops, so that 
a domain such as
+            // "com.." can not reach every host under "com"
+            return IDN.toASCII(name, IDN.ALLOW_UNASSIGNED);
+        } catch (IllegalArgumentException e) {
+            return null;
+        }
+    }
 
-            int tokenDif = hostTokens.length - domainTokens.length;
-            if (tokenDif < 0) {
+    /** Checks that an ascii domain label is made of letters, digits and inner 
hyphens. */
+    private static boolean isValidLabel(String label) {
+        if (label.isEmpty() || label.length() > 
EffectiveTldFinder.MAX_DOMAIN_LENGTH_PART) {
+            return false;
+        }
+        if (label.charAt(0) == '-' || label.charAt(label.length() - 1) == '-') 
{
+            return false;
+        }
+        for (int i = 0; i < label.length(); i++) {
+            final char c = label.charAt(i);
+            if (c != '-' && (c < 'a' || c > 'z') && (c < '0' || c > '9')) {
                 return false;
             }
+        }
+        return true;
+    }
 
-            for (int i = domainTokens.length - 1; i >= 0; i--) {
-                if (!domainTokens[i].equalsIgnoreCase(hostTokens[i + 
tokenDif])) {
-                    return false;
-                }
-            }
+    /**
+     * Checks whether a name is an address rather than a domain name. RFC 6265 
5.1.3 only lets a
+     * cookie go back to that exact address, so an address never covers 
anything below it.
+     *
+     * @param name a host name or a normalised cookie domain
+     * @return true when the name is an ipv4 or ipv6 address
+     */
+    private static boolean isAddress(String name) {
+        if (name.isEmpty()) {
+            return false;
+        }
+        // an ipv6 literal, bracketed as URL.getHost() returns it or bare
+        if (name.charAt(0) == '[' || name.indexOf(':') >= 0) {
             return true;
-        } catch (Exception e) {
+        }
+        // a top level domain is never made of digits only, so this is the 
last part of an
+        // address, e.g. "1.2.3.4" but also a truncation of it such as "2.3.4"
+        final String last = name.substring(name.lastIndexOf('.') + 1);
+        if (last.isEmpty()) {
+            return false;
+        }
+        for (int i = 0; i < last.length(); i++) {
+            final char c = last.charAt(i);
+            if (c < '0' || c > '9') {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Checks whether a cookie can be scoped to every host under a domain. The 
public suffix list
+     * shipped with crawler-commons is used, its private section included, so 
that domains such as
+     * "github.io" are treated as suffixes like browsers do. A domain which 
the list does not know
+     * about, e.g. an internal name under ".local", falls back to the plain 
suffix match so that
+     * unlisted domains keep working.
+     *
+     * @param domain a domain returned by {@link #normaliseDomain(String)}
+     * @return true when hosts below the domain are covered by it
+     */
+    private static boolean allowsSubdomains(String domain) {
+        if (domain.indexOf('.') < 0) {
+            // a single label is either an intranet host name or a bare top 
level domain
+            return false;
+        }
+        if (isAddress(domain)) {
+            return false;
+        }
+        if (EffectiveTldFinder.getEffectiveTLD(domain, false) == null) {
             return true;
         }
+        // the domain is covered by a public suffix: usable only when it sits 
below it
+        return EffectiveTldFinder.getAssignedDomain(domain, true, false) != 
null;
+    }
+
+    /** Checks whether a host is covered by a cookie domain. */
+    private static boolean hostMatches(String domain, String host, boolean 
subdomains) {
+        if (host == null) {
+            return false;
+        }
+        final String h = stripRootDot(host.toLowerCase(Locale.ROOT));
+        if (isAddress(h)) {
+            // RFC 6265 5.1.3: an address is only matched by itself
+            return domain.equals(h);
+        }
+        final String ascii = toAscii(h);
+        if (ascii == null) {
+            return false;
+        }
+        if (subdomains) {
+            return checkDomainMatchToUrl(domain, ascii);
+        }
+        // RFC 6265 5.3: a domain attribute which can not own subdomains is 
ignored and the
+        // cookie is bound to the host it was set on
+        return domain.equals(ascii);
+    }
+
+    /** Removes the root label from a domain name, e.g. "example.com." becomes 
"example.com". */
+    private static String stripRootDot(String name) {
+        return name.endsWith(".") ? name.substring(0, name.length() - 1) : 
name;
+    }
+
+    /**
+     * Helper method to check if url matches a cookie domain. It only answers 
whether the host falls
+     * under the domain and does not decide whether the domain may be used as 
a cookie scope, e.g.
+     * "com" matches "example.com".
+     *
+     * @param cookieDomain the domain in the cookie
+     * @param urlHostName the host name of the url
+     * @return does the cookie match the host name
+     */
+    public static boolean checkDomainMatchToUrl(String cookieDomain, String 
urlHostName) {
+        if (cookieDomain == null || urlHostName == null) {
+            return false;
+        }
+        if (cookieDomain.startsWith(".")) {
+            cookieDomain = cookieDomain.substring(1);
+        }
+        // the negative limit keeps the empty labels which the default split 
drops, so that
+        // a domain such as "com.." can not match every host under "com"
+        final String[] domainTokens = stripRootDot(cookieDomain).split("\\.", 
-1);
+        final String[] hostTokens = stripRootDot(urlHostName).split("\\.", -1);
+
+        final int tokenDif = hostTokens.length - domainTokens.length;
+        if (tokenDif < 0) {
+            return false;
+        }
+
+        for (int i = domainTokens.length - 1; i >= 0; i--) {
+            if (domainTokens[i].isEmpty()
+                    || !domainTokens[i].equalsIgnoreCase(hostTokens[i + 
tokenDif])) {
+                return false;
+            }
+        }
+        return true;
     }
 }
diff --git a/core/src/main/resources/crawler-default.yaml 
b/core/src/main/resources/crawler-default.yaml
index 0343f644..5293b8a3 100644
--- a/core/src/main/resources/crawler-default.yaml
+++ b/core/src/main/resources/crawler-default.yaml
@@ -73,7 +73,9 @@ config:
 
   # Lists the metadata to transfer to outlinks
   # Used by Fetcher and SiteMapParser for redirections,
-  # discovered links, passing cookies to child pages, etc.
+  # discovered links, passing cookies to child pages
+  # (protocol.set-cookie and protocol.set-cookie-origin,
+  # which have to be transferred together), etc.
   # These are also persisted for the parent document (see below).
   # Allows wildcards, eg. "follow.*" transfers all metadata starting with 
"follow.".
   # metadata.transfer:
diff --git 
a/core/src/test/java/org/apache/stormcrawler/protocol/okhttp/HttpProtocolCookieTest.java
 
b/core/src/test/java/org/apache/stormcrawler/protocol/okhttp/HttpProtocolCookieTest.java
new file mode 100644
index 00000000..a30d5248
--- /dev/null
+++ 
b/core/src/test/java/org/apache/stormcrawler/protocol/okhttp/HttpProtocolCookieTest.java
@@ -0,0 +1,204 @@
+/*
+ * 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.okhttp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import org.apache.storm.Config;
+import org.apache.stormcrawler.Metadata;
+import org.apache.stormcrawler.protocol.AbstractProtocolTest;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.handler.AbstractHandler;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Checks that the url whose response set the cookies is recorded in the 
metadata and used to send
+ * cookies without a domain attribute back to the host which set them.
+ */
+class HttpProtocolCookieTest extends AbstractProtocolTest {
+
+    private static final String ORIGIN_KEY = "set-cookie-origin";
+
+    private static final CookieHandler handler = new CookieHandler();
+
+    @BeforeEach
+    void resetHandler() {
+        handler.lastCookieHeader = null;
+    }
+
+    @Override
+    protected Handler[] getHandlers() {
+        return new Handler[] {handler};
+    }
+
+    @Test
+    void responseCarryingSetCookieRecordsTheOrigin() throws Exception {
+        final String url = url("/setcookie");
+        final Metadata md = fetch(protocol(false), url, new Metadata());
+        assertEquals(url, md.getFirstValue(ORIGIN_KEY), "the origin is the url 
which was fetched");
+    }
+
+    @Test
+    void responseWithoutSetCookieRecordsNoOrigin() throws Exception {
+        final Metadata md = fetch(protocol(false), url("/nocookie"), new 
Metadata());
+        assertNull(md.getFirstValue(ORIGIN_KEY), "no cookies, no origin");
+    }
+
+    @Test
+    void serverSuppliedOriginHeaderIsDiscarded() throws Exception {
+        final Metadata md = fetch(protocol(false), url("/forgedorigin"), new 
Metadata());
+        assertNull(md.getFirstValue(ORIGIN_KEY), "the origin can not be set by 
the server");
+    }
+
+    @Test
+    void serverSuppliedOriginHeaderIsOverwritten() throws Exception {
+        final String url = url("/setcookieandforgedorigin");
+        final Metadata md = fetch(protocol(false), url, new Metadata());
+        assertEquals(url, md.getFirstValue(ORIGIN_KEY), "the origin can not be 
set by the server");
+    }
+
+    @Test
+    void redirectResponseRecordsTheOrigin() throws Exception {
+        final String url = url("/loginredirect");
+        final Metadata md = fetch(protocol(false), url, new Metadata());
+        assertEquals(url, md.getFirstValue(ORIGIN_KEY), "a 302 can set cookies 
too");
+    }
+
+    @Test
+    void hostOnlyCookieIsSentBackToTheSameHost() throws Exception {
+        final HttpProtocol protocol = protocol(true);
+        final Metadata md = outlinkMetadata(protocol, url("/setcookie"));
+        fetch(protocol, url("/echo"), md);
+        assertEquals("sid=x", handler.lastCookieHeader, "the cookie is sent 
back to the same host");
+    }
+
+    @Test
+    void hostOnlyCookieIsNotSentToAnotherHost() throws Exception {
+        final HttpProtocol protocol = protocol(true);
+        final Metadata md = outlinkMetadata(protocol, url("/setcookie"));
+        fetch(protocol, "http://127.0.0.1:"; + HTTP_PORT + "/echo", md);
+        assertNull(handler.lastCookieHeader, "the cookie is bound to the host 
which set it");
+    }
+
+    @Test
+    void cookieWithoutRecordedOriginIsNotSent() throws Exception {
+        final HttpProtocol protocol = protocol(true);
+        final Metadata md = outlinkMetadata(protocol, url("/setcookie"));
+        md.remove("protocol." + ORIGIN_KEY);
+        fetch(protocol, url("/echo"), md);
+        assertNull(handler.lastCookieHeader, "without an origin the cookie can 
not be scoped");
+    }
+
+    @Test
+    void unparseableOriginIsIgnored() throws Exception {
+        final HttpProtocol protocol = protocol(true);
+        final Metadata md = outlinkMetadata(protocol, 
url("/setcookieforlocalhost"));
+        md.setValue("protocol." + ORIGIN_KEY, "not a url");
+        fetch(protocol, url("/echo"), md);
+        assertEquals(
+                "domainsid=y",
+                handler.lastCookieHeader,
+                "only the cookie without a domain attribute needs the origin");
+    }
+
+    /** Fetches a url and returns the metadata of the response. */
+    private Metadata fetch(HttpProtocol protocol, String url, Metadata md) 
throws Exception {
+        return protocol.getProtocolOutput(url, md).getMetadata();
+    }
+
+    /** Fetches a url and returns its response metadata as an outlink would 
inherit it. */
+    private Metadata outlinkMetadata(HttpProtocol protocol, String url) throws 
Exception {
+        final Metadata md = new Metadata();
+        md.putAll(fetch(protocol, url, new Metadata()), "protocol.");
+        return md;
+    }
+
+    private String url(String path) {
+        return "http://localhost:"; + HTTP_PORT + path;
+    }
+
+    private HttpProtocol protocol(boolean useCookies) {
+        final Config conf = new Config();
+        conf.put("http.agent.name", "test");
+        conf.put("http.agent.version", "1.0");
+        conf.put("http.agent.description", "test");
+        conf.put("http.agent.url", "http://test.example.com";);
+        conf.put("http.agent.email", "[email protected]");
+        conf.put("http.use.cookies", useCookies);
+        conf.put("protocol.md.prefix", "protocol.");
+        final HttpProtocol protocol = new HttpProtocol();
+        protocol.configure(conf);
+        return protocol;
+    }
+
+    static class CookieHandler extends AbstractHandler {
+
+        private volatile String lastCookieHeader;
+
+        @Override
+        public void handle(
+                String target,
+                Request baseRequest,
+                HttpServletRequest request,
+                HttpServletResponse response)
+                throws IOException {
+            baseRequest.setHandled(true);
+            switch (target) {
+                case "/setcookie":
+                    response.addHeader("Set-Cookie", "sid=x; Path=/");
+                    break;
+                case "/setcookieforlocalhost":
+                    response.addHeader("Set-Cookie", "sid=x; Path=/");
+                    response.addHeader("Set-Cookie", "domainsid=y; 
Domain=localhost; Path=/");
+                    break;
+                case "/forgedorigin":
+                    response.addHeader("Set-Cookie-Origin", 
"http://evil.example.com/";);
+                    break;
+                case "/setcookieandforgedorigin":
+                    response.addHeader("Set-Cookie", "sid=x; Path=/");
+                    response.addHeader("Set-Cookie-Origin", 
"http://evil.example.com/";);
+                    break;
+                case "/loginredirect":
+                    response.addHeader("Set-Cookie", "sid=x; Path=/");
+                    response.setHeader("Location", "/home");
+                    response.setStatus(HttpServletResponse.SC_FOUND);
+                    return;
+                case "/echo":
+                    lastCookieHeader = request.getHeader("Cookie");
+                    break;
+                default:
+                    break;
+            }
+            final byte[] content = "Success!".getBytes(StandardCharsets.UTF_8);
+            response.setStatus(HttpServletResponse.SC_OK);
+            response.setContentType("text/html");
+            response.setContentLength(content.length);
+            try (OutputStream out = response.getOutputStream()) {
+                out.write(content);
+            }
+        }
+    }
+}
diff --git 
a/core/src/test/java/org/apache/stormcrawler/util/CookieConverterTest.java 
b/core/src/test/java/org/apache/stormcrawler/util/CookieConverterTest.java
index 57b95f45..e5b651f3 100644
--- a/core/src/test/java/org/apache/stormcrawler/util/CookieConverterTest.java
+++ b/core/src/test/java/org/apache/stormcrawler/util/CookieConverterTest.java
@@ -40,7 +40,9 @@ class CookieConverterTest {
         String dummyCookieString =
                 buildCookieString(dummyCookieHeader, dummyCookieValue, null, 
null, null, null);
         cookiesStrings[0] = dummyCookieString;
-        List<Cookie> result = CookieConverter.getCookies(cookiesStrings, 
getUrl(unsecuredUrl));
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl));
         Assertions.assertEquals(1, result.size(), "Should have 1 cookie");
         Assertions.assertEquals(
                 dummyCookieHeader, result.get(0).getName(), "Cookie header 
should be as defined");
@@ -60,7 +62,9 @@ class CookieConverterTest {
                         null,
                         null);
         cookiesStrings[0] = dummyCookieString;
-        List<Cookie> result = CookieConverter.getCookies(cookiesStrings, 
getUrl(unsecuredUrl));
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl));
         Assertions.assertEquals(1, result.size(), "Should have 1 cookie");
         Assertions.assertEquals(
                 dummyCookieHeader, result.get(0).getName(), "Cookie header 
should be as defined");
@@ -80,7 +84,9 @@ class CookieConverterTest {
                         null,
                         null);
         cookiesStrings[0] = dummyCookieString;
-        List<Cookie> result = CookieConverter.getCookies(cookiesStrings, 
getUrl(unsecuredUrl));
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl));
         Assertions.assertEquals(
                 0, result.size(), "Should have 0 cookies, since cookie was 
expired");
     }
@@ -92,7 +98,8 @@ class CookieConverterTest {
                 buildCookieString(dummyCookieHeader, dummyCookieValue, null, 
null, "/", null);
         cookiesStrings[0] = dummyCookieString;
         List<Cookie> result =
-                CookieConverter.getCookies(cookiesStrings, getUrl(unsecuredUrl 
+ "/somepage"));
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl + "/somepage"));
         Assertions.assertEquals(1, result.size(), "Should have 1 cookie");
         Assertions.assertEquals(
                 dummyCookieHeader, result.get(0).getName(), "Cookie header 
should be as defined");
@@ -106,7 +113,9 @@ class CookieConverterTest {
         String dummyCookieString =
                 buildCookieString(dummyCookieHeader, dummyCookieValue, null, 
null, "/", null);
         cookiesStrings[0] = dummyCookieString;
-        List<Cookie> result = CookieConverter.getCookies(cookiesStrings, 
getUrl(unsecuredUrl));
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl));
         Assertions.assertEquals(1, result.size(), "Should have 1 cookie");
         Assertions.assertEquals(
                 dummyCookieHeader, result.get(0).getName(), "Cookie header 
should be as defined");
@@ -122,7 +131,8 @@ class CookieConverterTest {
                         dummyCookieHeader, dummyCookieValue, null, null, 
"/someFolder", null);
         cookiesStrings[0] = dummyCookieString;
         List<Cookie> result =
-                CookieConverter.getCookies(cookiesStrings, getUrl(unsecuredUrl 
+ "/someFolder"));
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl + "/someFolder"));
         Assertions.assertEquals(1, result.size(), "Should have 1 cookie");
         Assertions.assertEquals(
                 dummyCookieHeader, result.get(0).getName(), "Cookie header 
should be as defined");
@@ -139,7 +149,9 @@ class CookieConverterTest {
         cookiesStrings[0] = dummyCookieString;
         List<Cookie> result =
                 CookieConverter.getCookies(
-                        cookiesStrings, getUrl(unsecuredUrl + 
"/someFolder/SomeOtherFolder"));
+                        cookiesStrings,
+                        getUrl(unsecuredUrl),
+                        getUrl(unsecuredUrl + "/someFolder/SomeOtherFolder"));
         Assertions.assertEquals(1, result.size(), "Should have 1 cookie");
         Assertions.assertEquals(
                 dummyCookieHeader, result.get(0).getName(), "Cookie header 
should be as defined");
@@ -156,7 +168,9 @@ class CookieConverterTest {
         cookiesStrings[0] = dummyCookieString;
         List<Cookie> result =
                 CookieConverter.getCookies(
-                        cookiesStrings, getUrl(unsecuredUrl + 
"/someOtherFolder/SomeFolder"));
+                        cookiesStrings,
+                        getUrl(unsecuredUrl),
+                        getUrl(unsecuredUrl + "/someOtherFolder/SomeFolder"));
         Assertions.assertEquals(0, result.size(), "path mismatch, should have 
0 cookies");
     }
 
@@ -169,7 +183,9 @@ class CookieConverterTest {
         cookiesStrings[0] = dummyCookieString;
         List<Cookie> result =
                 CookieConverter.getCookies(
-                        cookiesStrings, getUrl(unsecuredUrl + 
"/someFolder/SomeOtherFolder"));
+                        cookiesStrings,
+                        getUrl(unsecuredUrl),
+                        getUrl(unsecuredUrl + "/someFolder/SomeOtherFolder"));
         Assertions.assertEquals(1, result.size(), "Should have 1 cookie");
         Assertions.assertEquals(
                 dummyCookieHeader, result.get(0).getName(), "Cookie header 
should be as defined");
@@ -186,7 +202,9 @@ class CookieConverterTest {
         cookiesStrings[0] = dummyCookieString;
         List<Cookie> result =
                 CookieConverter.getCookies(
-                        cookiesStrings, getUrl(unsecuredUrl + 
"/someFolder/SomeOtherFolder"));
+                        cookiesStrings,
+                        getUrl(unsecuredUrl),
+                        getUrl(unsecuredUrl + "/someFolder/SomeOtherFolder"));
         Assertions.assertEquals(0, result.size(), "Domain is not valid - 
Should have 0 cookies");
     }
 
@@ -199,7 +217,9 @@ class CookieConverterTest {
         cookiesStrings[0] = dummyCookieString;
         List<Cookie> result =
                 CookieConverter.getCookies(
-                        cookiesStrings, getUrl(unsecuredUrl + 
"/someFolder/SomeOtherFolder"));
+                        cookiesStrings,
+                        getUrl(unsecuredUrl),
+                        getUrl(unsecuredUrl + "/someFolder/SomeOtherFolder"));
         Assertions.assertEquals(
                 0, result.size(), "Target url is not secured - Should have 0 
cookies");
     }
@@ -213,7 +233,9 @@ class CookieConverterTest {
         cookiesStrings[0] = dummyCookieString;
         List<Cookie> result =
                 CookieConverter.getCookies(
-                        cookiesStrings, getUrl(securedUrl + 
"/someFolder/SomeOtherFolder"));
+                        cookiesStrings,
+                        getUrl(unsecuredUrl),
+                        getUrl(securedUrl + "/someFolder/SomeOtherFolder"));
         Assertions.assertEquals(1, result.size(), "Target url is  secured - 
Should have 1 cookie");
         Assertions.assertEquals(
                 dummyCookieHeader, result.get(0).getName(), "Cookie header 
should be as defined");
@@ -235,7 +257,9 @@ class CookieConverterTest {
         cookiesStrings[0] = dummyCookieString;
         List<Cookie> result =
                 CookieConverter.getCookies(
-                        cookiesStrings, getUrl(securedUrl + 
"/someFolder/SomeOtherFolder"));
+                        cookiesStrings,
+                        getUrl(unsecuredUrl),
+                        getUrl(securedUrl + "/someFolder/SomeOtherFolder"));
         Assertions.assertEquals(1, result.size(), "Should have 1 cookie");
         Assertions.assertEquals(
                 dummyCookieHeader, result.get(0).getName(), "Cookie header 
should be as defined");
@@ -266,7 +290,9 @@ class CookieConverterTest {
         cookiesStrings[1] = dummyCookieString2;
         List<Cookie> result =
                 CookieConverter.getCookies(
-                        cookiesStrings, getUrl(securedUrl + 
"/someFolder/SomeOtherFolder"));
+                        cookiesStrings,
+                        getUrl(unsecuredUrl),
+                        getUrl(securedUrl + "/someFolder/SomeOtherFolder"));
         Assertions.assertEquals(2, result.size(), "Should have 2 cookies");
         Assertions.assertEquals(
                 dummyCookieHeader, result.get(0).getName(), "Cookie header 
should be as defined");
@@ -282,6 +308,457 @@ class CookieConverterTest {
                 "Cookie value should be as defined");
     }
 
+    @Test
+    void cookieWithoutDomainIsSentToSameHost() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(dummyCookieHeader, dummyCookieValue, null, 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl + "/somepage"));
+        Assertions.assertEquals(1, result.size(), "Should have 1 cookie");
+    }
+
+    @Test
+    void cookieWithoutDomainIsNotSentToOtherHost() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(dummyCookieHeader, dummyCookieValue, null, 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl(unsecuredUrl), 
getUrl("http://someotherurl.com";));
+        Assertions.assertEquals(
+                0, result.size(), "Cookie without domain is bound to the host 
which set it");
+    }
+
+    @Test
+    void cookieWithoutDomainIsNotSentWhenOriginIsUnknown() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(dummyCookieHeader, dummyCookieValue, null, 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(cookiesStrings, null, 
getUrl(unsecuredUrl));
+        Assertions.assertEquals(
+                0, result.size(), "Cookie without domain needs the host which 
set it");
+    }
+
+    @Test
+    void cookieWithSingleLabelDomainIsNotSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(dummyCookieHeader, dummyCookieValue, "com", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl));
+        Assertions.assertEquals(0, result.size(), "Domain com must not match 
someurl.com");
+    }
+
+    @Test
+    void cookieWithDomainIsNotSentWhenOriginIsOutsideTheDomain() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "someurl.com", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings,
+                        getUrl("http://someotherurl.com";),
+                        getUrl(unsecuredUrl + "/somepage"));
+        Assertions.assertEquals(
+                0, result.size(), "Domain does not cover the host which set 
the cookie");
+    }
+
+    @Test
+    void cookieWithConsecutiveDotsInDomainIsNotSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(dummyCookieHeader, dummyCookieValue, 
"com..", null, null, null);
+        Assertions.assertEquals(
+                0,
+                CookieConverter.getCookies(
+                                cookiesStrings,
+                                getUrl("http://evil.com";),
+                                getUrl("http://victim.com";))
+                        .size(),
+                "Domain com.. must not match every host under com");
+        Assertions.assertEquals(
+                0,
+                CookieConverter.getCookies(
+                                cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl))
+                        .size(),
+                "Domain com.. must not match someurl.com");
+    }
+
+    @Test
+    void cookieWithTrailingDotsInDomainIsNotSent() {
+        for (String domain : new String[] {"com...", ".com.."}) {
+            String[] cookiesStrings = new String[1];
+            cookiesStrings[0] =
+                    buildCookieString(
+                            dummyCookieHeader, dummyCookieValue, domain, null, 
null, null);
+            Assertions.assertEquals(
+                    0,
+                    CookieConverter.getCookies(
+                                    cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl))
+                            .size(),
+                    "Domain " + domain + " is malformed");
+        }
+    }
+
+    @Test
+    void cookieWithEmptyLabelInDomainIsNotSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "some..url.com", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl));
+        Assertions.assertEquals(0, result.size(), "Domain with an empty label 
is malformed");
+    }
+
+    @Test
+    void cookieWithLeadingDoubleDotDomainIsNotSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "..someurl.com", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl));
+        Assertions.assertEquals(0, result.size(), "Only one leading dot is 
allowed");
+    }
+
+    @Test
+    void cookieWithTrailingRootDotDomainIsSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "someurl.com.", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl));
+        Assertions.assertEquals(1, result.size(), "The root label is not part 
of the domain");
+    }
+
+    @Test
+    void cookieWithInvalidCharactersInDomainIsNotSent() {
+        for (String domain :
+                new String[] {"some_url.com", "-someurl.com", "someurl-.com", 
"\"someurl.com\""}) {
+            String[] cookiesStrings = new String[1];
+            cookiesStrings[0] =
+                    buildCookieString(
+                            dummyCookieHeader, dummyCookieValue, domain, null, 
null, null);
+            Assertions.assertEquals(
+                    0,
+                    CookieConverter.getCookies(
+                                    cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl))
+                            .size(),
+                    "Domain " + domain + " is not a valid domain name");
+        }
+    }
+
+    @Test
+    void cookieWithOverlongLabelInDomainIsNotSent() {
+        String label = "a".repeat(64);
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, label + ".com", 
null, null, null);
+        Assertions.assertEquals(
+                0,
+                CookieConverter.getCookies(
+                                cookiesStrings,
+                                getUrl("http://"; + label + ".com"),
+                                getUrl("http://"; + label + ".com"))
+                        .size(),
+                "A label of more than 63 characters is not valid");
+
+        String validLabel = "a".repeat(63);
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, validLabel + 
".com", null, null, null);
+        Assertions.assertEquals(
+                1,
+                CookieConverter.getCookies(
+                                cookiesStrings,
+                                getUrl("http://"; + validLabel + ".com"),
+                                getUrl("http://"; + validLabel + ".com"))
+                        .size(),
+                "A label of 63 characters is valid");
+    }
+
+    @Test
+    void cookieWithEmptyDomainIsTreatedAsHostOnly() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(dummyCookieHeader, dummyCookieValue, "", 
null, null, null);
+        Assertions.assertEquals(
+                1,
+                CookieConverter.getCookies(
+                                cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl))
+                        .size(),
+                "An empty domain attribute binds the cookie to the host which 
set it");
+        Assertions.assertEquals(
+                0,
+                CookieConverter.getCookies(
+                                cookiesStrings,
+                                getUrl(unsecuredUrl),
+                                getUrl("http://someotherurl.com";))
+                        .size(),
+                "An empty domain attribute does not cover another host");
+    }
+
+    @Test
+    void cookieWithPublicSuffixDomainIsNotSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(dummyCookieHeader, dummyCookieValue, 
"co.uk", null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl("http://evil.co.uk";), 
getUrl("http://bank.co.uk";));
+        Assertions.assertEquals(0, result.size(), "A public suffix is not 
usable as a scope");
+    }
+
+    @Test
+    void cookieWithUpperCasePublicSuffixDomainIsNotSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(dummyCookieHeader, dummyCookieValue, 
"CO.UK", null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl("http://evil.co.uk";), 
getUrl("http://bank.co.uk";));
+        Assertions.assertEquals(
+                0, result.size(), "The public suffix lookup must not depend on 
the case");
+    }
+
+    @Test
+    void cookieWithMultiLabelPublicSuffixDomainIsNotSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "pvt.k12.ma.us", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings,
+                        getUrl("http://evil.pvt.k12.ma.us";),
+                        getUrl("http://victim.pvt.k12.ma.us";));
+        Assertions.assertEquals(
+                0, result.size(), "A multi label public suffix is not usable 
as a scope");
+    }
+
+    @Test
+    void cookieWithPrivateSectionSuffixDomainIsNotSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "github.io", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings,
+                        getUrl("http://evil.github.io";),
+                        getUrl("http://victim.github.io";));
+        Assertions.assertEquals(
+                0, result.size(), "A suffix of the private section is not 
usable as a scope");
+    }
+
+    @Test
+    void cookieWithWildcardPublicSuffixDomainIsNotSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(dummyCookieHeader, dummyCookieValue, 
"foo.ck", null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl("http://a.foo.ck";), 
getUrl("http://b.foo.ck";));
+        Assertions.assertEquals(
+                0, result.size(), "A suffix matched by a wildcard rule is not 
usable as a scope");
+    }
+
+    @Test
+    void cookieBelowPublicSuffixIsSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "example.co.uk", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings,
+                        getUrl("http://www.example.co.uk";),
+                        getUrl("http://shop.example.co.uk";));
+        Assertions.assertEquals(
+                1, result.size(), "A domain below a public suffix covers its 
hosts");
+    }
+
+    @Test
+    void cookieBelowPrivateSectionSuffixIsSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "user.github.io", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings,
+                        getUrl("http://www.user.github.io";),
+                        getUrl("http://blog.user.github.io";));
+        Assertions.assertEquals(
+                1, result.size(), "A domain below a private suffix covers its 
hosts");
+    }
+
+    @Test
+    void cookieWithUnlistedInternalDomainIsSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "intranet.local", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings,
+                        getUrl("http://host.intranet.local";),
+                        getUrl("http://other.intranet.local";));
+        Assertions.assertEquals(
+                1, result.size(), "A domain unknown to the public suffix list 
keeps working");
+    }
+
+    @Test
+    void cookieWithUnicodeDomainIsSent() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "münchen.de", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings,
+                        getUrl("http://münchen.de";),
+                        getUrl("http://www.münchen.de";));
+        Assertions.assertEquals(
+                1, result.size(), "A domain attribute written in unicode keeps 
scoping its cookie");
+    }
+
+    @Test
+    void cookieWithUnicodeDomainIsSentToThePunycodeHost() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "münchen.de", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings,
+                        getUrl("http://xn--mnchen-3ya.de";),
+                        getUrl("http://www.xn--mnchen-3ya.de";));
+        Assertions.assertEquals(
+                1, result.size(), "The unicode and punycode forms are 
interchangeable");
+    }
+
+    @Test
+    void cookieWithAddressDomainIsNotSentToAnotherAddress() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(dummyCookieHeader, dummyCookieValue, 
"2.3.4", null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl("http://1.2.3.4";), 
getUrl("http://9.2.3.4";));
+        Assertions.assertEquals(0, result.size(), "An address does not cover 
anything below it");
+    }
+
+    @Test
+    void cookieWithAddressDomainIsSentToThatAddress() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(dummyCookieHeader, dummyCookieValue, 
"1.2.3.4", null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl("http://1.2.3.4";), 
getUrl("http://1.2.3.4";));
+        Assertions.assertEquals(1, result.size(), "An address is matched by 
itself");
+    }
+
+    @Test
+    void cookieHeaderWithoutANameIsSkipped() {
+        String[] cookiesStrings = new String[] {"novalue", "=novalue", 
"sid=ok"};
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings, getUrl(unsecuredUrl), 
getUrl(unsecuredUrl));
+        Assertions.assertEquals(1, result.size(), "Only the usable cookie is 
kept");
+        Assertions.assertEquals("sid", result.get(0).getName());
+    }
+
+    @Test
+    void cookieWithUnlistedInternalDomainIsSent2() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "host.internal", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings,
+                        getUrl("http://a.host.internal";),
+                        getUrl("http://b.host.internal";));
+        Assertions.assertEquals(
+                1, result.size(), "A domain unknown to the public suffix list 
keeps working");
+    }
+
+    @Test
+    void cookieWithUnlistedDomainIsNotSentToLookalikeHost() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "intranet.local", 
null, null, null);
+        List<Cookie> result =
+                CookieConverter.getCookies(
+                        cookiesStrings,
+                        getUrl("http://host.intranet.local";),
+                        getUrl("http://intranet.local.evil.com";));
+        Assertions.assertEquals(
+                0, result.size(), "The domain must be a suffix of the host, 
not a prefix");
+    }
+
+    @Test
+    void cookieWithSingleLabelIntranetDomainIsSentToThatHostOnly() {
+        String[] cookiesStrings = new String[1];
+        cookiesStrings[0] =
+                buildCookieString(
+                        dummyCookieHeader, dummyCookieValue, "intranet", null, 
null, null);
+        Assertions.assertEquals(
+                1,
+                CookieConverter.getCookies(
+                                cookiesStrings,
+                                getUrl("http://intranet";),
+                                getUrl("http://intranet/somepage";))
+                        .size(),
+                "A single label domain binds the cookie to that host");
+        Assertions.assertEquals(
+                0,
+                CookieConverter.getCookies(
+                                cookiesStrings,
+                                getUrl("http://intranet";),
+                                getUrl("http://other.intranet";))
+                        .size(),
+                "A single label domain does not cover its subdomains");
+    }
+
+    @Test
+    void domainsCheckerRejectsEmptyLabels() {
+        Assertions.assertFalse(
+                CookieConverter.checkDomainMatchToUrl("com..", "evil.com"),
+                "an empty label must not be dropped");
+        Assertions.assertFalse(
+                CookieConverter.checkDomainMatchToUrl("..example.com", 
"www.example.com"),
+                "an empty label must not be dropped");
+    }
+
+    @Test
+    void domainsCheckerRejectsNullDomain() {
+        Assertions.assertFalse(
+                CookieConverter.checkDomainMatchToUrl(null, "example.com"),
+                "domain can not be checked");
+    }
+
+    @Test
+    void domainsCheckerIgnoresTheRootLabelOfTheHost() {
+        Assertions.assertTrue(
+                CookieConverter.checkDomainMatchToUrl("example.com", 
"www.example.com."),
+                "the root label is not part of the host name");
+    }
+
     @Test
     void testDomainsChecker() {
         boolean result = CookieConverter.checkDomainMatchToUrl(".example.com", 
"www.example.com");
@@ -306,6 +783,12 @@ class CookieConverterTest {
         Assertions.assertFalse(result, "domain is not valid");
     }
 
+    @Test
+    void testDomainsChecker5() {
+        boolean result = CookieConverter.checkDomainMatchToUrl("example.com", 
null);
+        Assertions.assertFalse(result, "domain can not be checked");
+    }
+
     private URL getUrl(String urlString) {
         try {
             return URLUtil.toURL(urlString);
diff --git 
a/core/src/test/java/org/apache/stormcrawler/util/MetadataTransferTest.java 
b/core/src/test/java/org/apache/stormcrawler/util/MetadataTransferTest.java
index b2b297ad..43368e97 100644
--- a/core/src/test/java/org/apache/stormcrawler/util/MetadataTransferTest.java
+++ b/core/src/test/java/org/apache/stormcrawler/util/MetadataTransferTest.java
@@ -54,6 +54,44 @@ class MetadataTransferTest {
         Assertions.assertEquals(1, urlpath.length);
     }
 
+    @Test
+    void testCookieTransfer() throws MalformedURLException {
+        Map<String, Object> conf = new HashMap<>();
+        conf.put(
+                MetadataTransfer.metadataTransferParamName,
+                List.of("protocol.set-cookie", "protocol.set-cookie-origin"));
+        Metadata parentMD = new Metadata();
+        parentMD.addValue("protocol.set-cookie", "sid=42; Path=/");
+        parentMD.addValue("protocol.set-cookie-origin", 
"http://www.example.com";);
+        Metadata outlinkMD =
+                MetadataTransfer.getInstance(conf)
+                        .getMetaForOutlink(
+                                "http://www.example.com/outlink.html";,
+                                "http://www.example.com";,
+                                parentMD);
+        Assertions.assertEquals("sid=42; Path=/", 
outlinkMD.getFirstValue("protocol.set-cookie"));
+        Assertions.assertEquals(
+                "http://www.example.com";, 
outlinkMD.getFirstValue("protocol.set-cookie-origin"));
+    }
+
+    @Test
+    void testCookieTransferWithWildcard() throws MalformedURLException {
+        Map<String, Object> conf = new HashMap<>();
+        conf.put(MetadataTransfer.metadataTransferParamName, 
List.of("protocol.set-cookie*"));
+        Metadata parentMD = new Metadata();
+        parentMD.addValue("protocol.set-cookie", "sid=42; Path=/");
+        parentMD.addValue("protocol.set-cookie-origin", 
"http://www.example.com";);
+        Metadata outlinkMD =
+                MetadataTransfer.getInstance(conf)
+                        .getMetaForOutlink(
+                                "http://www.example.com/outlink.html";,
+                                "http://www.example.com";,
+                                parentMD);
+        Assertions.assertEquals("sid=42; Path=/", 
outlinkMD.getFirstValue("protocol.set-cookie"));
+        Assertions.assertEquals(
+                "http://www.example.com";, 
outlinkMD.getFirstValue("protocol.set-cookie-origin"));
+    }
+
     @Test
     void testCustomTransferClass() throws MalformedURLException {
         Map<String, Object> conf = new HashMap<>();
diff --git a/docs/src/main/asciidoc/configuration.adoc 
b/docs/src/main/asciidoc/configuration.adoc
index 7aaa0ccf..9f272d8e 100644
--- a/docs/src/main/asciidoc/configuration.adoc
+++ b/docs/src/main/asciidoc/configuration.adoc
@@ -224,7 +224,7 @@ implementation.
 | robots.noFollow.strict | true | If true, remove all outlinks from pages 
marked as noFollow.
 | http.store.headers | false | Whether to store response headers.
 | http.timeout | 10000 | Connection timeout (ms).
-| http.use.cookies | false | Use cookies in subsequent requests.
+| http.use.cookies | false | Use cookies in subsequent requests. Requires 
`protocol.set-cookie` and `protocol.set-cookie-origin` in `metadata.transfer`.
 | https.protocol.implementation | 
org.apache.stormcrawler.protocol.okhttp.HttpProtocol | HTTPS Protocol
 implementation.
 | partition.url.mode | byHost | Defines how URLs are partitioned: byHost, 
byDomain, or byIP.
diff --git a/docs/src/main/asciidoc/internals.adoc 
b/docs/src/main/asciidoc/internals.adoc
index ff74e9a4..7c11e40e 100644
--- a/docs/src/main/asciidoc/internals.adoc
+++ b/docs/src/main/asciidoc/internals.adoc
@@ -475,7 +475,9 @@ The `metadata` argument to 
link:https://github.com/apache/stormcrawler/blob/main
 
 * `http.accept.language`: If this key is present in `metadata`, the protocol 
will use the value to override the value for the `Accept-Language` header field 
of the HTTP request. If the key is not present, the `http.accept.language` 
global configuration value is used instead. (Available in v1.11+)
 
-* `protocol.set-cookie`: If this key is present in `metadata` and 
`http.use.cookies` is true, the protocol will send cookies stored from the 
response this page was linked to, given the cookie is applicable to the domain 
of the link.
+* `protocol.set-cookie`: If this key is present in `metadata` and 
`http.use.cookies` is true, the protocol sends the cookies stored from the 
response this page was linked to, given the cookie applies to the url being 
fetched. A cookie without a `Domain` attribute is only sent when the url has 
the same host as the response which set it, which requires 
`protocol.set-cookie-origin` to be present too.
+
+* `protocol.set-cookie-origin`: Written by the protocol next to 
`protocol.set-cookie` when a response carries a `Set-Cookie` header, and holds 
the url of that response. It must be transferred and persisted together with 
`protocol.set-cookie`, otherwise cookies without a `Domain` attribute are not 
sent. A value sent by a server as a header of that name is discarded.
 
 * `http.method.head`: If this key is present in `metadata`, the protocol sends 
a HEAD request. (see link:https://github.com/apache/stormcrawler/pull/923[#923])
 
@@ -510,13 +512,17 @@ metadata.persist:
   - last-modified
   - protocol.etag
   - protocol.set-cookie
+  - protocol.set-cookie-origin
   - ...
 ----
 
-* Cookies need to be transferred to outlinks by setting:
+* Cookies and the host which set them need to be transferred to outlinks by 
setting:
 
 [source,yaml]
 ----
 metadata.transfer:
-  - set-cookie
+  - protocol.set-cookie
+  - protocol.set-cookie-origin
 ----
+
+The wildcard `protocol.set-cookie*` covers both keys.
diff --git 
a/external/playwright/src/main/java/org/apache/stormcrawler/protocol/playwright/HttpProtocol.java
 
b/external/playwright/src/main/java/org/apache/stormcrawler/protocol/playwright/HttpProtocol.java
index 3a9561d0..3e947e91 100644
--- 
a/external/playwright/src/main/java/org/apache/stormcrawler/protocol/playwright/HttpProtocol.java
+++ 
b/external/playwright/src/main/java/org/apache/stormcrawler/protocol/playwright/HttpProtocol.java
@@ -227,6 +227,7 @@ public class HttpProtocol extends AbstractHttpProtocol {
                                                         (k, v) -> {
                                                             
responseMetaData.addValue(k, v);
                                                         });
+                                        recordCookieOrigin(responseMetaData, 
url);
                                         storeVerbatimHeaders(response, 
responseMetaData);
                                     }
                                 }
@@ -271,6 +272,7 @@ public class HttpProtocol extends AbstractHttpProtocol {
                                         (k, v) -> {
                                             responseMetaData.addValue(k, v);
                                         });
+                        recordCookieOrigin(responseMetaData, url);
                         storeVerbatimHeaders(response, responseMetaData);
 
                         int httpStatus = response.status();
@@ -330,6 +332,22 @@ public class HttpProtocol extends AbstractHttpProtocol {
         }
     }
 
+    /**
+     * Records the url of the response which carried the cookies, as the 
Set-Cookie header does not
+     * say which host sent it and the cookies are scoped to that host when 
they are sent back. The
+     * key is always removed first, so that a response sending a header of 
that name can not choose
+     * the host its cookies are sent to.
+     *
+     * @param responseMetaData the metadata built from the response headers
+     * @param url the url of the response
+     */
+    private static void recordCookieOrigin(final Metadata responseMetaData, 
final String url) {
+        responseMetaData.remove(RESPONSE_COOKIES_ORIGIN);
+        if (responseMetaData.getFirstValue(RESPONSE_COOKIES_HEADER) != null) {
+            responseMetaData.setValue(RESPONSE_COOKIES_ORIGIN, url);
+        }
+    }
+
     /**
      * Reconstructs verbatim request and response header blocks and stores 
them in the metadata
      * under the same keys as the other protocol implementations, so that 
downstream consumers (e.g.

Reply via email to