Skip to content

Commit

Permalink
KNOX-3037 - Client credentials flow accepts essential parameters in t…
Browse files Browse the repository at this point in the history
…he request body only
  • Loading branch information
smolnar82 committed May 8, 2024
1 parent 6c26ec6 commit f407740
Show file tree
Hide file tree
Showing 6 changed files with 237 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,10 @@ public interface JWTMessages {
@Message( level = MessageLevel.WARN, text = "Unable to derive authentication provider URL: {0}" )
void failedToDeriveAuthenticationProviderUrl(@StackTrace( level = MessageLevel.ERROR) Exception e);

@Message( level = MessageLevel.ERROR,
text = "The configuration value ({0}) for maximum token verification cache is invalid; Using the default value." )
@Message( level = MessageLevel.ERROR, text = "The configuration value ({0}) for maximum token verification cache is invalid; Using the default value." )
void invalidVerificationCacheMaxConfiguration(String value);

@Message( level = MessageLevel.ERROR,
text = "Missing token passcode." )
@Message( level = MessageLevel.ERROR, text = "Missing token passcode." )
void missingTokenPasscode();

@Message( level = MessageLevel.INFO, text = "Initialized token signature verification cache for the {0} topology." )
Expand All @@ -114,4 +112,10 @@ public interface JWTMessages {
@Message( level = MessageLevel.INFO, text = "Idle timeout has been configured to {0} seconds in {1}" )
void configuredIdleTimeout(long idleTimeout, String topology);

@Message(level = MessageLevel.WARN, text = "Client secret passed as a query parameter and exposed in the logs.")
void clientSecretExposed();

This comment has been minimized.

Copy link
@lmccay

lmccay May 8, 2024

Contributor

As you mentioned offline, this isn't a great idea. Raises a flag for the wrong people. I would rather see something like "Bad Request: violation of security best practices" or maybe it is even better to not log anything. Just fail with a 400 bad request.

This comment has been minimized.

Copy link
@smolnar82

smolnar82 May 8, 2024

Author Contributor

I'll make the necessary changes soon.

@Message(level = MessageLevel.ERROR, text = "Error while fetching grant type and client secret from the request: {0}")
void errorFetchingClientSecret(String errorMessage, @StackTrace(level = MessageLevel.DEBUG) Exception e);

}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.apache.knox.gateway.util.AuthFilterUtils.DEFAULT_AUTH_UNAUTHENTICATED_PATHS_PARAM;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.Base64;
import java.util.HashSet;
Expand Down Expand Up @@ -50,6 +54,7 @@
import org.apache.knox.gateway.util.AuthFilterUtils;
import org.apache.knox.gateway.util.CertificateUtils;
import org.apache.knox.gateway.util.CookieUtils;
import org.apache.knox.gateway.util.RequestUtils;

import com.nimbusds.jose.JOSEObjectType;

Expand Down Expand Up @@ -224,7 +229,7 @@ private String decodeBase64(String toBeDecoded) {
return new String(Base64.getDecoder().decode(toBeDecoded.getBytes(UTF_8)), UTF_8);
}

public Pair<TokenType, String> getWireToken(final ServletRequest request) {
public Pair<TokenType, String> getWireToken(final ServletRequest request) throws IOException {
Pair<TokenType, String> parsed = null;
String token = null;
final String header = ((HttpServletRequest)request).getHeader("Authorization");
Expand Down Expand Up @@ -253,12 +258,9 @@ public Pair<TokenType, String> getWireToken(final ServletRequest request) {
}

return parsed;
}

private Pair<TokenType, String> parseFromClientCredentialsFlow(ServletRequest request) {
Pair<TokenType, String> parsed = null;
String token = null;
}

private Pair<TokenType, String> parseFromClientCredentialsFlow(ServletRequest request) throws IOException {
/*
POST /{tenant}/oauth2/v2.0/token HTTP/1.1
Host: login.microsoftonline.com:443
Expand All @@ -270,15 +272,41 @@ private Pair<TokenType, String> parseFromClientCredentialsFlow(ServletRequest re
&grant_type=client_credentials
*/

String grantType = request.getParameter(GRANT_TYPE);
if (CLIENT_CREDENTIALS.equals(grantType)) {
// this is indeed a client credentials flow client_id and
// client_secret are expected now the client_id will be in
// the token as the token_id so we will get that later
token = request.getParameter(CLIENT_SECRET);
parsed = Pair.of(TokenType.Passcode, token);
if (request.getParameter(CLIENT_SECRET) != null) {
log.clientSecretExposed();
}

This comment has been minimized.

Copy link
@lmccay

lmccay May 8, 2024

Contributor

I think this needs to explicitly fail. Even if it is ALSO in the request body, it should not be sent as a query param and should fail as a 400 bad request due to security best practices.

return parsed;
return getClientCredentialsFromRequestBody(request);
}

private Pair<TokenType, String> getClientCredentialsFromRequestBody(ServletRequest request) throws IOException {
try {
final String requestBodyString = getRequestBodyString(request);
final String grantType = RequestUtils.getRequestBodyParameter(requestBodyString, GRANT_TYPE);
if (CLIENT_CREDENTIALS.equals(grantType)) {
// this is indeed a client credentials flow client_id and
// client_secret are expected now the client_id will be in
// the token as the token_id so we will get that later
final String clientSecret = RequestUtils.getRequestBodyParameter(requestBodyString, CLIENT_SECRET);
return Pair.of(TokenType.Passcode, clientSecret);
}
} catch (IOException e) {
log.errorFetchingClientSecret(e.getMessage(), e);
throw e;
}
return null;
}

private String getRequestBodyString(ServletRequest request) throws IOException {
if (request.getInputStream() != null) {
final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(request.getInputStream()));
final StringBuilder requestBodyBuilder = new StringBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
requestBodyBuilder.append(line);
}
return URLDecoder.decode(requestBodyBuilder.toString(), StandardCharsets.UTF_8.name());
}
return null;
}

private Pair<TokenType, String> parseFromHTTPBasicCredentials(final String header) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,59 @@
package org.apache.knox.gateway.provider.federation;


import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter;
import org.apache.knox.gateway.provider.federation.jwt.filter.JWTFederationFilter.TokenType;
import org.apache.knox.test.mock.MockServletInputStream;
import org.easymock.EasyMock;
import org.junit.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;


public class ClientIdAndClientSecretFederationFilterTest extends TokenIDAsHTTPBasicCredsFederationFilterTest {
@Override
protected void setTokenOnRequest(HttpServletRequest request, String authUsername, String authPassword) {
EasyMock.expect((Object)request.getHeader("Authorization")).andReturn("");
EasyMock.expect((Object)request.getParameter("grant_type")).andReturn("client_credentials");
EasyMock.expect((Object)request.getParameter("client_id")).andReturn(authUsername);
EasyMock.expect((Object)request.getParameter("client_secret")).andReturn(authPassword);
try {
EasyMock.expect(request.getInputStream()).andAnswer(() -> produceServletInputStream(authPassword)).atLeastOnce();
} catch (IOException e) {
throw new RuntimeException("Error while setting up expectation for getting client credentials from request body", e);
}
}

private ServletInputStream produceServletInputStream(String clientSecret) {
final String requestBody = JWTFederationFilter.GRANT_TYPE + "=" + JWTFederationFilter.CLIENT_CREDENTIALS + "&" + JWTFederationFilter.CLIENT_SECRET + "="
+ clientSecret;
final InputStream inputStream = IOUtils.toInputStream(requestBody, StandardCharsets.UTF_8);
return new MockServletInputStream(inputStream);
}


@Test
public void testGetWireTokenUsingClientCredentialsFlow() throws Exception {
final String clientSecret = "sup3r5ecreT!";
final HttpServletRequest request = EasyMock.createNiceMock(HttpServletRequest.class);
EasyMock.expect(request.getInputStream()).andAnswer(() -> produceServletInputStream(clientSecret)).atLeastOnce();
EasyMock.replay(request);

handler.init(new TestFilterConfig(getProperties()));
final Pair<TokenType, String> wireToken = ((TestJWTFederationFilter) handler).getWireToken(request);

EasyMock.verify(request);

assertNotNull(wireToken);
assertEquals(TokenType.Passcode, wireToken.getLeft());
assertEquals(clientSecret, wireToken.getRight());
}

@Override
Expand Down
5 changes: 5 additions & 0 deletions gateway-util-common/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,10 @@
<artifactId>gateway-test-utils</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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.knox.gateway.util;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;

import javax.servlet.ServletRequest;

public class RequestUtils {

This comment has been minimized.

Copy link
@lmccay

lmccay May 8, 2024

Contributor

Perhaps a rename to RequestBodyUtils? I am also thinking that all of this code is likely assuming that the content type is Content-Type: application/x-www-form-urlencoded. What do we need to consider for json and XML based payloads? We can probably leave that as a follow up but giving it some thought now would probably make sense.


public static String getRequestBodyParameter(ServletRequest request, String parameter) throws IOException {
return getRequestBodyParameter(request, parameter, false);
}

public static String getRequestBodyParameter(ServletRequest request, String parameter, boolean decode) throws IOException {
return getRequestBodyParameter(request.getInputStream(), parameter, decode);
}

public static String getRequestBodyParameter(InputStream inputStream, String parameter) throws IOException {
return getRequestBodyParameter(inputStream, parameter, false);
}

public static String getRequestBodyParameter(InputStream inputStream, String parameter, boolean decode) throws IOException {
return getRequestBodyParameter(new InputStreamReader(inputStream, StandardCharsets.UTF_8), parameter, decode);
}

public static String getRequestBodyParameter(Reader reader, String parameter) throws IOException {
return getRequestBodyParameter(reader, parameter, false);
}

public static String getRequestBodyParameter(Reader reader, String parameter, boolean decode) throws IOException {
final BufferedReader bufferedReader = new BufferedReader(reader);
final StringBuilder requestBodyBuilder = new StringBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
requestBodyBuilder.append(line);
}

final String requestBodyString = decode ? URLDecoder.decode(requestBodyBuilder.toString(), StandardCharsets.UTF_8.name()) : requestBodyBuilder.toString();
return getRequestBodyParameter(requestBodyString, parameter);
}

public static String getRequestBodyParameter(String requestBodyString, String parameter) {
if (requestBodyString != null) {
final String[] requestBodyParams = requestBodyString.split("&");
for (String requestBodyParam : requestBodyParams) {
String[] keyValue = requestBodyParam.split("=", 2);
if (parameter.equals(keyValue[0])) {
return keyValue[1];
}
}
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* 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.knox.gateway.util;

import static org.junit.Assert.assertEquals;

import java.io.InputStream;
import java.nio.charset.StandardCharsets;

import javax.servlet.ServletInputStream;
import javax.servlet.ServletRequest;

import org.apache.commons.io.IOUtils;
import org.apache.knox.test.mock.MockServletInputStream;
import org.easymock.EasyMock;
import org.junit.Test;

public class RequestUtilsTest {

private static final String REQUEST_BODY_PARAM_NAME = "myParam";
private static final String REQUEST_BODY_PARAM_VALUE_RAW = "This-is_my sample text!";
private static final String REQUEST_BODY_PARAM_VALUE_ENCODED = "This-is_my%20sample%20text%21";

@Test
public void testGetRequestBodyParameterEncoded() throws Exception {
testGetRequestBodyParameter(true);
}

@Test
public void testGetRequestBodyParameterRaw() throws Exception {
testGetRequestBodyParameter(false);
}

private void testGetRequestBodyParameter(boolean decode) throws Exception {
final ServletRequest request = EasyMock.createNiceMock(ServletRequest.class);
EasyMock.expect(request.getInputStream()).andReturn(produceServletInputStream(decode)).anyTimes();

EasyMock.replay(request);

final String requestBodyParam = RequestUtils.getRequestBodyParameter(request, REQUEST_BODY_PARAM_NAME, decode);
assertEquals(REQUEST_BODY_PARAM_VALUE_RAW, requestBodyParam);
}

private ServletInputStream produceServletInputStream(boolean encode) {
final String requestBody = REQUEST_BODY_PARAM_NAME + "=" + (encode ? REQUEST_BODY_PARAM_VALUE_ENCODED : REQUEST_BODY_PARAM_VALUE_RAW);
final InputStream inputStream = IOUtils.toInputStream(requestBody, StandardCharsets.UTF_8);
return new MockServletInputStream(inputStream);
}

}

1 comment on commit f407740

@lmccay
Copy link
Contributor

@lmccay lmccay commented on f407740 May 8, 2024

Choose a reason for hiding this comment

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

Made a couple comments mostly around what we discussed offline.

Please sign in to comment.