Skip to content
Merged
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
2 changes: 2 additions & 0 deletions solr/CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ Improvements

* SOLR-16927: Allow SolrClientCache clients to use Jetty HTTP2 clients (Alex Deparvu, David Smiley)

* SOLR-16896, SOLR-16897: Add support of OAuth 2.0/OIDC 'code with PKCE' flow (Lamine Idjeraoui, janhoy, Kevin Risden)
Comment thread
janhoy marked this conversation as resolved.
Outdated

* SOLR-16879: Limit the number of concurrent expensive core admin operations by running them in a
dedicated thread pool. Backup, Restore and Split are expensive operations.
(Pierre Salagnac, David Smiley)
Expand Down
37 changes: 35 additions & 2 deletions solr/core/src/java/org/apache/solr/servlet/LoadAdminUiServlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@
*/
package org.apache.solr.servlet;

import com.google.common.net.HttpHeaders;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.io.output.CloseShieldOutputStream;
Expand All @@ -37,6 +41,8 @@ public final class LoadAdminUiServlet extends BaseSolrServlet {
// check system properties for whether or not admin UI is disabled, default is false
private static final boolean disabled =
Boolean.parseBoolean(System.getProperty("disableAdminUI", "false"));
// list of comma separated URLs to inject into the CSP connect-src directive
public static final String SYSPROP_CSP_CONNECT_SRC_URLS = "solr.ui.headers.csp.connect-src.urls";

@Override
public void doGet(HttpServletRequest _request, HttpServletResponse _response) throws IOException {
Expand All @@ -60,20 +66,47 @@ public void doGet(HttpServletRequest _request, HttpServletResponse _response) th
if (in != null && cores != null) {
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html");
String connectSrc = generateCspConnectSrc();
response.setHeader(
HttpHeaders.CONTENT_SECURITY_POLICY,
"default-src 'none'; base-uri 'none'; connect-src "
+ connectSrc
+ "; form-action 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self';");

// We have to close this to flush OutputStreamWriter buffer
try (Writer out =
new OutputStreamWriter(
CloseShieldOutputStream.wrap(response.getOutputStream()), StandardCharsets.UTF_8)) {
Package pack = SolrCore.class.getPackage();
String html =
new String(in.readAllBytes(), StandardCharsets.UTF_8)
.replace("${version}", pack.getSpecificationVersion());
.replace("${version}", getSolrCorePackageSpecVersion());
out.write(html);
}
} else {
response.sendError(404);
}
}
}

/**
* Retrieves the specification version of the SolrCore package.
*
* @return The specification version of the SolrCore class's package or Unknown if it's
* unavailable.
*/
private String getSolrCorePackageSpecVersion() {
Package pack = SolrCore.class.getPackage();
return pack.getSpecificationVersion() != null ? pack.getSpecificationVersion() : "Unknown";
}

/**
* Fetch the value of {@link #SYSPROP_CSP_CONNECT_SRC_URLS} system property, split by comma, and
* concatenate them into a space-separated string that can be used in CSP connect-src directive
*/
private String generateCspConnectSrc() {
String cspURLs = System.getProperty(SYSPROP_CSP_CONNECT_SRC_URLS, "");
List<String> props = new ArrayList<>(Arrays.asList(cspURLs.split(",")));
props.add("'self'");
return String.join(" ", props);
}
}
102 changes: 102 additions & 0 deletions solr/core/src/test/org/apache/solr/servlet/LoadAdminUiServletTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* 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.solr.servlet;

import static org.apache.solr.servlet.LoadAdminUiServlet.SYSPROP_CSP_CONNECT_SRC_URLS;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.solr.SolrTestCaseJ4;
import org.apache.solr.core.CoreContainer;
import org.junit.After;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

public class LoadAdminUiServletTest extends SolrTestCaseJ4 {

@InjectMocks private LoadAdminUiServlet servlet;
@Mock private HttpServletRequest mockRequest;
@Mock private HttpServletResponse mockResponse;
@Mock private CoreContainer coreContainer;
@Mock private ServletConfig servletConfig;
@Mock private ServletContext mockServletContext;
@Mock private ServletOutputStream mockOutputStream;

private static final Set<String> CSP_URLS =
Set.of(
"http://example1.com/token",
"https://example2.com/path/uri1",
"http://example3.com/oauth2/uri2");

@Override
@Before
public void setUp() throws Exception {
super.setUp();
MockitoAnnotations.openMocks(this);
when(mockRequest.getRequestURI()).thenReturn("/path/URI");
when(mockRequest.getContextPath()).thenReturn("/path");
when(mockRequest.getAttribute("org.apache.solr.CoreContainer")).thenReturn(coreContainer);
when(servletConfig.getServletContext()).thenReturn(mockServletContext);
when(mockResponse.getOutputStream()).thenReturn(mockOutputStream);
InputStream mockInputStream =
new ByteArrayInputStream("mock content".getBytes(StandardCharsets.UTF_8));
when(mockServletContext.getResourceAsStream(anyString())).thenReturn(mockInputStream);
}

@BeforeClass
public static void ensureWorkingMockito() {
assumeWorkingMockito();
}

@Test
public void testDefaultCSPHeaderSet() throws IOException {
System.setProperty(SYSPROP_CSP_CONNECT_SRC_URLS, String.join(",", CSP_URLS));
ArgumentCaptor<String> headerNameCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> headerValueCaptor = ArgumentCaptor.forClass(String.class);
servlet.doGet(mockRequest, mockResponse);

verify(mockResponse).setHeader(headerNameCaptor.capture(), headerValueCaptor.capture());
assertEquals("Content-Security-Policy", headerNameCaptor.getValue());
String cspValue = headerValueCaptor.getValue();
for (String endpoint : CSP_URLS) {
assertTrue("Expected CSP value to contain " + endpoint, cspValue.contains(endpoint));
}
}

@Override
@After
public void tearDown() throws Exception {
super.tearDown();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
import org.apache.solr.security.ConfigEditablePlugin;
import org.apache.solr.security.jwt.JWTAuthPlugin.JWTAuthenticationResponse.AuthCode;
import org.apache.solr.security.jwt.api.ModifyJWTAuthPluginConfigAPI;
import org.apache.solr.servlet.LoadAdminUiServlet;
import org.apache.solr.util.CryptoKeys;
import org.eclipse.jetty.client.api.Request;
import org.jose4j.jwa.AlgorithmConstraints;
Expand Down Expand Up @@ -130,7 +131,9 @@ public class JWTAuthPlugin extends AuthenticationPlugin
JWTIssuerConfig.PARAM_CLIENT_ID,
JWTIssuerConfig.PARAM_WELL_KNOWN_URL,
JWTIssuerConfig.PARAM_AUDIENCE,
JWTIssuerConfig.PARAM_AUTHORIZATION_ENDPOINT);
JWTIssuerConfig.PARAM_AUTHORIZATION_ENDPOINT,
JWTIssuerConfig.PARAM_TOKEN_ENDPOINT,
JWTIssuerConfig.PARAM_AUTHORIZATION_FLOW);

private JwtConsumer jwtConsumer;
private boolean requireExpirationTime;
Expand Down Expand Up @@ -280,10 +283,24 @@ public void init(Map<String, Object> pluginConfig) {
}

initConsumer();
registerTokenEndpointForCsp();

lastInitTime = Instant.now();
}

/**
* Record Issuer token URL as a system property so it can be picked up and sent to Admin UI as CSP
*/
protected void registerTokenEndpointForCsp() {
final String syspropName = LoadAdminUiServlet.SYSPROP_CSP_CONNECT_SRC_URLS;
String url = !issuerConfigs.isEmpty() ? getPrimaryIssuer().getTokenEndpoint() : null;
if (url != null) {
System.setProperty(syspropName, url);
} else {
System.clearProperty(syspropName);
}
}

/**
* Given a configuration object of a file name or list of file names, read X509 certificates from
* each file
Expand Down Expand Up @@ -336,6 +353,8 @@ private Optional<JWTIssuerConfig> parseIssuerFromTopLevelConfig(Map<String, Obje
.setJwksUrl(conf.get(JWTIssuerConfig.PARAM_JWKS_URL))
.setAuthorizationEndpoint(
(String) conf.get(JWTIssuerConfig.PARAM_AUTHORIZATION_ENDPOINT))
.setTokenEndpoint((String) conf.get(JWTIssuerConfig.PARAM_TOKEN_ENDPOINT))
.setAuthorizationFlow((String) conf.get(JWTIssuerConfig.PARAM_AUTHORIZATION_FLOW))
.setClientId((String) conf.get(JWTIssuerConfig.PARAM_CLIENT_ID))
.setWellKnownUrl((String) conf.get(JWTIssuerConfig.PARAM_WELL_KNOWN_URL));
if (conf.get(JWTIssuerConfig.PARAM_JWK) != null) {
Expand Down Expand Up @@ -847,9 +866,11 @@ protected String generateAuthDataHeader() {
Map<String, Object> data = new HashMap<>();
data.put(
JWTIssuerConfig.PARAM_AUTHORIZATION_ENDPOINT, primaryIssuer.getAuthorizationEndpoint());
Comment thread
janhoy marked this conversation as resolved.
Outdated
data.put(JWTIssuerConfig.PARAM_TOKEN_ENDPOINT, primaryIssuer.getTokenEndpoint());
Comment thread
janhoy marked this conversation as resolved.
Outdated
data.put("client_id", primaryIssuer.getClientId());
data.put("scope", adminUiScope);
data.put("redirect_uris", redirectUris);
data.put("authorization_flow", primaryIssuer.getAuthorizationFlow());
String headerJson = Utils.toJSONString(data);
return Base64.getEncoder().encodeToString(headerJson.getBytes(StandardCharsets.UTF_8));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.invoke.MethodHandles;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.Charset;
Expand All @@ -32,26 +33,33 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.util.StrUtils;
import org.apache.solr.common.util.Utils;
import org.jose4j.http.Get;
import org.jose4j.http.SimpleResponse;
import org.jose4j.jwk.HttpsJwks;
import org.jose4j.jwk.JsonWebKey;
import org.jose4j.jwk.JsonWebKeySet;
import org.jose4j.lang.JoseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Holds information about an IdP (issuer), such as issuer ID, JWK url(s), keys etc */
public class JWTIssuerConfig {
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
static final String PARAM_ISS_NAME = "name";
static final String PARAM_JWKS_URL = "jwksUrl";
static final String PARAM_JWK = "jwk";
static final String PARAM_ISSUER = "iss";
static final String PARAM_AUDIENCE = "aud";
static final String PARAM_WELL_KNOWN_URL = "wellKnownUrl";
static final String PARAM_AUTHORIZATION_ENDPOINT = "authorizationEndpoint";
static final String PARAM_TOKEN_ENDPOINT = "tokenEndpoint";
static final String PARAM_CLIENT_ID = "clientId";
static final String PARAM_AUTHORIZATION_FLOW = "authorizationFlow";

private static HttpsJwksFactory httpsJwksFactory = new HttpsJwksFactory(3600, 5000);
private String iss;
Expand All @@ -64,12 +72,18 @@ public class JWTIssuerConfig {
private WellKnownDiscoveryConfig wellKnownDiscoveryConfig;
private String clientId;
private String authorizationEndpoint;
private String tokenEndpoint;
private String authorizationFlow;
private Collection<X509Certificate> trustedCerts;

public static boolean ALLOW_OUTBOUND_HTTP =
Boolean.parseBoolean(System.getProperty("solr.auth.jwt.allowOutboundHttp", "false"));
public static final String ALLOW_OUTBOUND_HTTP_ERR_MSG =
"HTTPS required for IDP communication. Please use SSL or start your nodes with -Dsolr.auth.jwt.allowOutboundHttp=true to allow HTTP for test purposes.";
private static final String DEFAULT_AUTHORIZATION_FLOW =
"implicit"; // 'implicit' to be deprecated
private static final Set<String> VALID_AUTHORIZATION_FLOWS =
Set.of(DEFAULT_AUTHORIZATION_FLOW, "code_pkce");

/**
* Create config for further configuration with setters, builder style. Once all values are set,
Expand Down Expand Up @@ -117,6 +131,10 @@ public void init() {
if (authorizationEndpoint == null) {
authorizationEndpoint = wellKnownDiscoveryConfig.getAuthorizationEndpoint();
}

if (tokenEndpoint == null) {
tokenEndpoint = wellKnownDiscoveryConfig.getTokenEndpoint();
}
}
if (iss == null && usesHttpsJwk() && !JWTAuthPlugin.PRIMARY_ISSUER.equals(name)) {
throw new SolrException(
Expand All @@ -141,6 +159,8 @@ protected void parseConfigMap(Map<String, Object> configMap) {
setJwksUrl(confJwksUrl);
setJsonWebKeySet(conf.get(PARAM_JWK));
setAuthorizationEndpoint((String) conf.get(PARAM_AUTHORIZATION_ENDPOINT));
setTokenEndpoint((String) conf.get(PARAM_TOKEN_ENDPOINT));
setAuthorizationFlow((String) conf.get(PARAM_AUTHORIZATION_FLOW));

conf.remove(PARAM_WELL_KNOWN_URL);
conf.remove(PARAM_ISSUER);
Expand All @@ -150,6 +170,8 @@ protected void parseConfigMap(Map<String, Object> configMap) {
conf.remove(PARAM_JWKS_URL);
conf.remove(PARAM_JWK);
conf.remove(PARAM_AUTHORIZATION_ENDPOINT);
conf.remove(PARAM_TOKEN_ENDPOINT);
conf.remove(PARAM_AUTHORIZATION_FLOW);

if (!conf.isEmpty()) {
throw new SolrException(
Expand Down Expand Up @@ -315,6 +337,41 @@ public JWTIssuerConfig setAuthorizationEndpoint(String authorizationEndpoint) {
return this;
}

public String getTokenEndpoint() {
return tokenEndpoint;
}

public JWTIssuerConfig setTokenEndpoint(String tokenEndpoint) {
this.tokenEndpoint = tokenEndpoint;
return this;
}

public String getAuthorizationFlow() {
return authorizationFlow;
}

public JWTIssuerConfig setAuthorizationFlow(String authorizationFlow) {
this.authorizationFlow =
StrUtils.isNullOrEmpty(authorizationFlow)
? DEFAULT_AUTHORIZATION_FLOW
: authorizationFlow.trim();
if (!VALID_AUTHORIZATION_FLOWS.contains(this.authorizationFlow)) {
throw new SolrException(
SolrException.ErrorCode.SERVER_ERROR,
"Invalid value for "
+ PARAM_AUTHORIZATION_FLOW
+ ". Expected one of "
+ VALID_AUTHORIZATION_FLOWS
+ " but found "
+ authorizationFlow);
}
if (this.authorizationFlow.equals("implicit")) {
log.warn(
"JWT authentication plugin is using 'implicit flow' which is deprecated and less secure. It's recommended to switch to 'code_pkce'");
}
return this;
}

public Map<String, Object> asConfig() {
HashMap<String, Object> config = new HashMap<>();
putIfNotNull(config, PARAM_ISS_NAME, name);
Expand All @@ -324,6 +381,8 @@ public Map<String, Object> asConfig() {
putIfNotNull(config, PARAM_WELL_KNOWN_URL, wellKnownUrl);
putIfNotNull(config, PARAM_CLIENT_ID, clientId);
putIfNotNull(config, PARAM_AUTHORIZATION_ENDPOINT, authorizationEndpoint);
putIfNotNull(config, PARAM_TOKEN_ENDPOINT, tokenEndpoint);
putIfNotNull(config, PARAM_AUTHORIZATION_FLOW, authorizationFlow);
if (jsonWebKeySet != null) {
putIfNotNull(config, PARAM_JWK, jsonWebKeySet.getJsonWebKeys());
}
Expand Down
Loading