This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch branch_3x
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/branch_3x by this push:
new c4e8989ad5 credentials only on first hop (#3515)
c4e8989ad5 is described below
commit c4e8989ad5e1878b5e9c274cc16dcfb2b8f237fe
Author: Tim Allison <[email protected]>
AuthorDate: Mon Nov 2 20:47:54 2026 -0500
credentials only on first hop (#3515)
---
CHANGES.txt | 8 +
.../http/HttpFetcherRedirectCredentialsTest.java | 267 +++++++++++++++++++++
.../org/apache/tika/client/HttpClientFactory.java | 52 ++++
3 files changed, 327 insertions(+)
diff --git a/CHANGES.txt b/CHANGES.txt
index 0558791ae7..ee7e520d7b 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,13 @@
Release 3.4.0 - ???
+ * The http fetcher, the Solr and OpenSearch emitters and the Solr pipes
iterator no longer
+ send Authorization or Cookie headers, or answer a 401 with the configured
password,
+ after a redirect to another host, port or scheme. That includes http to
https, so use
+ the https URL in the fetch key. Applies only with maxRedirects > 0
(TIKA-XXXX).
+
+ * The http fetcher with authScheme basic or ntlm no longer fails to
initialize with
+ "Username may not be null" (TIKA-XXXX).
+
* tika-age-recogniser is deprecated and will be removed in 3.5.0; it depends
on the
unmaintained age-predictor-api on Spark 2.2 / Scala 2.10. It was removed
in 4.0.0
(TIKA-4343).
diff --git
a/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/HttpFetcherRedirectCredentialsTest.java
b/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/HttpFetcherRedirectCredentialsTest.java
new file mode 100644
index 0000000000..8de01594a0
--- /dev/null
+++
b/tika-pipes/tika-fetchers/tika-fetcher-http/src/test/java/org/apache/tika/pipes/fetcher/http/HttpFetcherRedirectCredentialsTest.java
@@ -0,0 +1,267 @@
+/*
+ * 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.tika.pipes.fetcher.http;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.pipes.fetcher.http.config.HttpFetcherConfig;
+
+/**
+ * Credentials reach the host a fetch started at and no other. The two origins
differ by port,
+ * which HttpClient treats as another host.
+ */
+public class HttpFetcherRedirectCredentialsTest {
+
+ private static final String BASIC = "Basic "
+ +
Base64.getEncoder().encodeToString("user:secret".getBytes(UTF_8));
+
+ private Origin first;
+ private Origin second;
+
+ @BeforeEach
+ public void before() throws IOException {
+ first = new Origin();
+ second = new Origin();
+ }
+
+ @AfterEach
+ public void after() throws IOException {
+ first.close();
+ second.close();
+ }
+
+ @Test
+ public void testJwtAndCookieDoNotFollowARedirectToAnotherHost() throws
Exception {
+ first.redirect("/start", second.url("/doc"));
+ second.document("/doc");
+
+ Metadata metadata = new Metadata();
+ metadata.add("httpRequestHeaders", "Cookie: session=abc");
+ metadata.add("httpRequestHeaders", "X-Trace: 1");
+ assertEquals("/doc", fetch(jwtConfig(), first.url("/start"),
metadata));
+
+ Seen atFirst = first.seen.get(0);
+ assertTrue(atFirst.authorization().startsWith("Bearer "),
atFirst.toString());
+ assertEquals("session=abc", atFirst.cookie());
+
+ Seen atSecond = second.seen.get(0);
+ assertNull(atSecond.authorization());
+ assertNull(atSecond.cookie());
+ assertEquals("1", atSecond.trace());
+ }
+
+ @Test
+ public void testCredentialsStayOffEveryLaterHop() throws Exception {
+ // no User-Agent: Authorization is the only header on the request, and
HttpClient refills
+ // a redirect that has no headers from the original request
+ first.redirect("/start", second.url("/hop"));
+ second.redirect("/hop", second.url("/doc"));
+ second.document("/doc");
+
+ assertEquals("/doc", fetch(jwtConfig(), first.url("/start"), new
Metadata()));
+
+ assertEquals(2, second.seen.size());
+ for (Seen seen : second.seen) {
+ assertNull(seen.authorization(), seen.toString());
+ }
+ }
+
+ @Test
+ public void testBasicChallengeFromAnotherHostIsNotAnswered() throws
Exception {
+ first.redirect("/start", second.url("/doc"));
+ second.challenge("/doc");
+
+ IOException e = assertThrows(IOException.class,
+ () -> fetch(basicConfig(), first.url("/start"), new
Metadata()));
+
+ assertTrue(e.getMessage().contains("bad status code: 401"),
e.getMessage());
+ assertEquals(1, second.seen.size());
+ assertNull(second.seen.get(0).authorization());
+ }
+
+ @Test
+ public void testSameHostRedirectKeepsJwt() throws Exception {
+ first.redirect("/start", first.url("/doc"));
+ first.document("/doc");
+
+ assertEquals("/doc", fetch(jwtConfig(), first.url("/start"), new
Metadata()));
+
+ assertEquals(2, first.seen.size());
+ for (Seen seen : first.seen) {
+ assertTrue(seen.authorization().startsWith("Bearer "),
seen.toString());
+ }
+ }
+
+ @Test
+ public void testSameHostRedirectStillAnswersBasicChallenge() throws
Exception {
+ first.redirect("/start", first.url("/doc"));
+ first.challenge("/doc");
+
+ assertEquals("/doc", fetch(basicConfig(), first.url("/start"), new
Metadata()));
+
+ assertEquals(BASIC, first.seen.get(first.seen.size() -
1).authorization());
+ }
+
+ private static HttpFetcherConfig redirectingConfig() {
+ return new HttpFetcherConfig().setMaxRedirects(5);
+ }
+
+ private static HttpFetcherConfig jwtConfig() {
+ HttpFetcherConfig config = redirectingConfig();
+ config.setJwtSecret("0123456789abcdef0123456789abcdef");
+ config.setJwtIssuer("issuer");
+ config.setJwtSubject("subject");
+ return config;
+ }
+
+ private static HttpFetcherConfig basicConfig() {
+ return
redirectingConfig().setAuthScheme("basic").setUserName("user").setPassword("secret");
+ }
+
+ private static String fetch(HttpFetcherConfig config, String url, Metadata
metadata)
+ throws Exception {
+ HttpFetcher fetcher = new HttpFetcher(config);
+ fetcher.initialize(Collections.emptyMap());
+ try (InputStream is = fetcher.fetch(url, metadata, new
ParseContext())) {
+ return new String(is.readAllBytes(), UTF_8);
+ }
+ }
+
+ private static class Seen {
+ private final String authorization;
+ private final String cookie;
+ private final String trace;
+
+ Seen(String authorization, String cookie, String trace) {
+ this.authorization = authorization;
+ this.cookie = cookie;
+ this.trace = trace;
+ }
+
+ String authorization() {
+ return authorization;
+ }
+
+ String cookie() {
+ return cookie;
+ }
+
+ String trace() {
+ return trace;
+ }
+
+ @Override
+ public String toString() {
+ return "Seen[authorization=" + authorization + ", cookie=" +
cookie + ", trace="
+ + trace + "]";
+ }
+ }
+
+ /** A loopback server that records what each request carried and answers
with its path. */
+ private static class Origin {
+ private final ServerSocket server;
+ private final Map<String, Function<Map<String, String>, String>>
routes =
+ new ConcurrentHashMap<>();
+ private final List<Seen> seen = new CopyOnWriteArrayList<>();
+
+ Origin() throws IOException {
+ server = new ServerSocket(0, 50,
InetAddress.getByName("127.0.0.1"));
+ Thread acceptor = new Thread(this::serve);
+ acceptor.setDaemon(true);
+ acceptor.start();
+ }
+
+ String url(String path) {
+ return "http://127.0.0.1:" + server.getLocalPort() + path;
+ }
+
+ void redirect(String path, String location) {
+ routes.put(path, headers -> response("302 Found", "Location: " +
location + "\r\n", ""));
+ }
+
+ void document(String path) {
+ routes.put(path, headers -> response("200 OK", "", path));
+ }
+
+ void challenge(String path) {
+ routes.put(path, headers -> headers.containsKey("authorization")
+ ? response("200 OK", "", path)
+ : response("401 Unauthorized", "WWW-Authenticate: Basic
realm=\"tika\"\r\n", ""));
+ }
+
+ private static String response(String status, String headers, String
body) {
+ return "HTTP/1.1 " + status + "\r\n" + headers + "Content-Length:
" + body.length()
+ + "\r\nConnection: close\r\n\r\n" + body;
+ }
+
+ private void serve() {
+ while (!server.isClosed()) {
+ try (Socket client = server.accept()) {
+ answer(client);
+ } catch (IOException e) {
+ return;
+ }
+ }
+ }
+
+ private void answer(Socket client) throws IOException {
+ BufferedReader in = new BufferedReader(
+ new InputStreamReader(client.getInputStream(), UTF_8));
+ String path = in.readLine().split(" ")[1];
+ Map<String, String> headers = new HashMap<>();
+ for (String line = in.readLine(); line != null && !line.isEmpty();
line = in.readLine()) {
+ int colon = line.indexOf(':');
+ headers.put(line.substring(0, colon).toLowerCase(Locale.ROOT),
+ line.substring(colon + 1).trim());
+ }
+ seen.add(new Seen(headers.get("authorization"),
headers.get("cookie"),
+ headers.get("x-trace")));
+
client.getOutputStream().write(routes.get(path).apply(headers).getBytes(UTF_8));
+ }
+
+ void close() throws IOException {
+ server.close();
+ }
+ }
+}
diff --git
a/tika-pipes/tika-httpclient-commons/src/main/java/org/apache/tika/client/HttpClientFactory.java
b/tika-pipes/tika-httpclient-commons/src/main/java/org/apache/tika/client/HttpClientFactory.java
index d5ce55ea48..64a9cbe4b0 100644
---
a/tika-pipes/tika-httpclient-commons/src/main/java/org/apache/tika/client/HttpClientFactory.java
+++
b/tika-pipes/tika-httpclient-commons/src/main/java/org/apache/tika/client/HttpClientFactory.java
@@ -29,6 +29,7 @@ import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashSet;
+import java.util.List;
import java.util.Set;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
@@ -41,8 +42,10 @@ import javax.net.ssl.SSLContext;
import org.apache.http.Header;
import org.apache.http.HeaderElement;
import org.apache.http.HeaderElementIterator;
+import org.apache.http.HttpHeaders;
import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
+import org.apache.http.HttpRequestInterceptor;
import org.apache.http.HttpResponse;
import org.apache.http.ProtocolException;
import org.apache.http.auth.AuthSchemeProvider;
@@ -54,6 +57,7 @@ import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.HttpClient;
import org.apache.http.client.config.AuthSchemes;
import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.ConnectionKeepAliveStrategy;
@@ -68,6 +72,7 @@ import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.client.LaxRedirectStrategy;
+import org.apache.http.impl.client.TargetAuthenticationStrategy;
import org.apache.http.impl.conn.DefaultProxyRoutePlanner;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.message.BasicHeaderElementIterator;
@@ -259,6 +264,7 @@ public class HttpClientFactory {
cp.setMaxConnections(maxConnections);
cp.setNtDomain(ntDomain);
cp.setPassword(password);
+ cp.setUserName(userName);
cp.setProxyHost(proxyHost);
cp.setProxyPort(proxyPort);
cp.setRequestTimeout(requestTimeout);
@@ -305,6 +311,9 @@ public class HttpClientFactory {
}
addCredentialsProvider(builder);
addProxy(builder);
+ CredentialsHostGuard credentialsHostGuard = new CredentialsHostGuard();
+ builder.addInterceptorFirst(credentialsHostGuard)
+ .setTargetAuthenticationStrategy(credentialsHostGuard);
return builder.setConnectionManager(manager)
.setRedirectStrategy(new
CustomRedirectStrategy(allowedHostsForRedirect))
.setDefaultRequestConfig(getDefaultRequestConfig())
@@ -453,6 +462,49 @@ public class HttpClientFactory {
}
}
+ /**
+ * Keeps credentials on the host a request started at. HttpClient copies
the original
+ * request's headers onto every redirect and {@link AuthScope#ANY} answers
a challenge from
+ * any host, so a redirect would otherwise hand them to whichever host it
names. Another
+ * port or scheme counts as another host, as it does when HttpClient
resets its auth state.
+ */
+ private static class CredentialsHostGuard extends
TargetAuthenticationStrategy
+ implements HttpRequestInterceptor {
+
+ private static final String FIRST_TARGET =
+ CredentialsHostGuard.class.getName() + ".firstTarget";
+
+ // Runs per hop, unlike a redirect strategy: RedirectExec refills a
headerless redirect
+ // from the original request, so a strategy cannot take every header
off one.
+ @Override
+ public void process(HttpRequest request, HttpContext context) {
+ HttpClientContext clientContext = HttpClientContext.adapt(context);
+ if (!isRedirected(clientContext)) {
+ context.setAttribute(FIRST_TARGET,
clientContext.getTargetHost());
+ } else if (leftFirstTarget(clientContext)) {
+ request.removeHeaders(HttpHeaders.AUTHORIZATION);
+ request.removeHeaders("Cookie");
+ }
+ }
+
+ @Override
+ public boolean isAuthenticationRequested(HttpHost authhost,
HttpResponse response,
+ HttpContext context) {
+ return !leftFirstTarget(HttpClientContext.adapt(context))
+ && super.isAuthenticationRequested(authhost, response,
context);
+ }
+
+ private static boolean isRedirected(HttpClientContext context) {
+ List<URI> redirects = context.getRedirectLocations();
+ return redirects != null && !redirects.isEmpty();
+ }
+
+ private static boolean leftFirstTarget(HttpClientContext context) {
+ return isRedirected(context)
+ &&
!context.getTargetHost().equals(context.getAttribute(FIRST_TARGET));
+ }
+ }
+
private static class AES {
private final SecretKeySpec secretKey;