/*
* 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.wicket.core.request.mapper;
import java.util.Iterator;
import java.util.List;
import java.util.function.Supplier;
import org.apache.wicket.Application;
import org.apache.wicket.core.request.handler.RequestSettingRequestHandler;
import org.apache.wicket.protocol.http.PageExpiredException;
import org.apache.wicket.request.IRequestHandler;
import org.apache.wicket.request.IRequestMapper;
import org.apache.wicket.request.Request;
import org.apache.wicket.request.Url;
import org.apache.wicket.request.mapper.IRequestMapperDelegate;
import org.apache.wicket.request.mapper.info.PageComponentInfo;
import org.apache.wicket.util.crypt.ICrypt;
import org.apache.wicket.util.crypt.ICryptFactory;
import org.apache.wicket.util.lang.Args;
import org.apache.wicket.util.string.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* <p>
* A request mapper that encrypts URLs generated by another mapper. This mapper encrypts the segments
* and query parameters of URLs starting with {@link IMapperContext#getNamespace()}, and just the
* {@link PageComponentInfo} parameter for mounted URLs.
* </p>
*
* <p>
* <strong>Important</strong>: for better security it is recommended to use
* {@link org.apache.wicket.core.request.mapper.CryptoMapper#CryptoMapper(IRequestMapper, Supplier)}
* constructor with {@link org.apache.wicket.util.crypt.ICrypt} implementation that generates a
* separate key for each user. {@link org.apache.wicket.core.util.crypt.KeyInSessionSunJceCryptFactory} provides such an
* implementation that stores the key in the HTTP session.
* </p>
*
* <p>
* This mapper can be mounted before or after mounting other pages, but will only encrypt URLs for
* pages mounted before the {@link CryptoMapper}. If required, multiple {@link CryptoMapper}s may be
* installed in an {@link Application}.
* </p>
*
* <p>
* When encrypting URLs in the Wicket namespace (starting with {@link IMapperContext#getNamespace()}), the entire URL,
* including segments and parameters, is encrypted, with the encrypted form stored in the first segment of the encrypted URL.
* </p>
*
* <p>
* To be able to handle relative URLs, like for image URLs in a CSS file, checksum segments are appended to the
* encrypted URL until the encrypted URL has the same number of segments as the original URL had.
* Each checksum segment has a precise 5 character value, calculated using a checksum. This helps in calculating
* the relative distance from the original URL. When a URL is returned by the browser, we iterate through these
* checksummed placeholder URL segments. If the segment matches the expected checksum, then the segment is deemed
* to be the corresponding segment in the original URL. If the segment does not match the expected checksum, then
* the segment is deemed a plain text sibling of the corresponding segment in the original URL, and all subsequent
* segments are considered plain text children of the current segment.
* </p>
*
* <p>
* When encrypting mounted URLs, we look for the {@link PageComponentInfo} parameter, and encrypt only that parameter.
* </p>
*
* <p>
* {@link CryptoMapper} can be configured to mark encrypted URLs as encrypted, and throw a {@link PageExpiredException}
* exception if a encrypted URL cannot be decrypted. This can occur when using {@code KeyInSessionSunJceCryptFactory}, and
* the session has expired.
* </p>
*
* @author igor.vaynberg
* @author Jesse Long
* @author svenmeier
* @see org.apache.wicket.settings.SecuritySettings#setCryptFactory(org.apache.wicket.util.crypt.ICryptFactory)
* @see org.apache.wicket.core.util.crypt.KeyInSessionSunJceCryptFactory
* @see org.apache.wicket.util.crypt.SunJceCrypt
*/
public class CryptoMapper implements IRequestMapperDelegate
{
private static final Logger log = LoggerFactory.getLogger(CryptoMapper.class);
/**
* Name of the parameter which contains encrypted page component info.
*/
private static final String ENCRYPTED_PAGE_COMPONENT_INFO_PARAMETER = "wicket-crypt";
private static final String ENCRYPTED_URL_MARKER_PREFIX = "crypt.";
private final IRequestMapper wrappedMapper;
private final Supplier<ICrypt> cryptProvider;
/**
* Whether or not to mark encrypted URLs as encrypted.
*/
private boolean markEncryptedUrls = false;
/**
* Encrypt with {@link org.apache.wicket.settings.SecuritySettings#getCryptFactory()}.
* <p>
* <strong>Important</strong>: Encryption is done with {@link org.apache.wicket.settings.SecuritySettings#DEFAULT_ENCRYPTION_KEY} if you haven't
* configured an alternative {@link ICryptFactory}. For better security it is recommended to use
* {@link CryptoMapper#CryptoMapper(IRequestMapper, Supplier)} with a specific {@link ICrypt} implementation
* that generates a separate key for each user.
* {@link org.apache.wicket.core.util.crypt.KeyInSessionSunJceCryptFactory} provides such an implementation that stores the
* key in the HTTP session.
* </p>
*
* @param wrappedMapper
* the non-crypted request mapper
* @param application
* the current application
* @see org.apache.wicket.util.crypt.SunJceCrypt
*/
public CryptoMapper(final IRequestMapper wrappedMapper, final Application application)
{
this(wrappedMapper, () -> application.getSecuritySettings().getCryptFactory().newCrypt());
}
/**
* Construct.
*
* @param wrappedMapper
* the non-crypted request mapper
* @param cryptProvider
* the custom crypt provider
*/
public CryptoMapper(final IRequestMapper wrappedMapper, final Supplier<ICrypt> cryptProvider)
{
this.wrappedMapper = Args.notNull(wrappedMapper, "wrappedMapper");
this.cryptProvider = Args.notNull(cryptProvider, "cryptProvider");
}
/**
* Whether or not to mark encrypted URLs as encrypted. If set, a {@link PageExpiredException} is thrown when
* a encrypted URL can no longer be decrypted.
*
* @return whether or not to mark encrypted URLs as encrypted.
*/
public boolean getMarkEncryptedUrls()
{
return markEncryptedUrls;
}
/**
* Sets whether or not to mark encrypted URLs as encrypted. If set, a {@link PageExpiredException} is thrown when
* a encrypted URL can no longer be decrypted.
*
* @param markEncryptedUrls
* whether or not to mark encrypted URLs as encrypted.
*
* @return {@code this}, for chaining.
*/
public CryptoMapper setMarkEncryptedUrls(boolean markEncryptedUrls)
{
this.markEncryptedUrls = markEncryptedUrls;
return this;
}
/**
* {@inheritDoc}
* <p>
* This implementation decrypts the URL and passes the decrypted URL to the wrapped mapper.
* </p>
* @param request
* The request for which to get a compatibility score.
*
* @return The compatibility score.
*/
@Override
public int getCompatibilityScore(final Request request)
{
Url decryptedUrl = decryptUrl(request, request.getUrl());
if (decryptedUrl == null)
{
return 0;
}
Request decryptedRequest = request.cloneWithUrl(decryptedUrl);
return wrappedMapper.getCompatibilityScore(decryptedRequest);
}
@Override
public Url mapHandler(final IRequestHandler requestHandler)
{
final Url url = wrappedMapper.mapHandler(requestHandler);
if (url == null)
{
return null;
}
if (url.isFull())
{
// do not encrypt full urls
return url;
}
return encryptUrl(url);
}
@Override
public IRequestHandler mapRequest(final Request request)
{
Url url = decryptUrl(request, request.getUrl());
if (url == null)
{
return null;
}
Request decryptedRequest = request.cloneWithUrl(url);
IRequestHandler handler = wrappedMapper.mapRequest(decryptedRequest);
if (handler != null)
{
handler = new RequestSettingRequestHandler(decryptedRequest, handler);
}
return handler;
}
/**
* @return the {@link ICrypt} implementation that may be used to encrypt/decrypt {@link Url}'s
* segments and/or query string
*/
protected final ICrypt getCrypt()
{
return cryptProvider.get();
}
/**
* @return the wrapped root request mapper
*/
@Override
public final IRequestMapper getDelegateMapper()
{
return wrappedMapper;
}
/**
* Returns the applications {@link IMapperContext}.
*
* @return The applications {@link IMapperContext}.
*/
protected IMapperContext getContext()
{
return Application.get().getMapperContext();
}
/**
* Encrypts a URL. This method should return a new, encrypted instance of the URL. If the URL starts with {@code /wicket/},
* the entire URL is encrypted.
*
* @param url
* The URL to encrypt.
*
* @return A new, encrypted version of the URL.
*/
protected Url encryptUrl(final Url url)
{
if (url.getSegments().size() > 0
&& url.getSegments().get(0).equals(getContext().getNamespace()))
{
return encryptEntireUrl(url);
}
else
{
return encryptRequestListenerParameter(url);
}
}
/**
* Encrypts an entire URL, segments and query parameters.
*
* @param url
* The URL to encrypt.
*
* @return An encrypted form of the URL.
*/
protected Url encryptEntireUrl(final Url url)
{
String encryptedUrlString = getCrypt().encryptUrlSafe(url.toString());
Url encryptedUrl = new Url(url.getCharset());
if (getMarkEncryptedUrls())
{
encryptedUrl.getSegments().add(ENCRYPTED_URL_MARKER_PREFIX + encryptedUrlString);
}
else
{
encryptedUrl.getSegments().add(encryptedUrlString);
}
int numberOfSegments = url.getSegments().size() - 1;
HashedSegmentGenerator generator = new HashedSegmentGenerator(encryptedUrlString);
for (int segNo = 0; segNo < numberOfSegments; segNo++)
{
encryptedUrl.getSegments().add(generator.next());
}
return encryptedUrl;
}
/**
* Encrypts the {@link PageComponentInfo} query parameter in the URL, if any is found.
*
* @param url
* The URL to encrypt.
*
* @return An encrypted form of the URL.
*/
protected Url encryptRequestListenerParameter(final Url url)
{
Url encryptedUrl = new Url(url);
boolean encrypted = false;
for (Iterator<Url.QueryParameter> it = encryptedUrl.getQueryParameters().iterator(); it.hasNext();)
{
Url.QueryParameter qp = it.next();
if (MapperUtils.parsePageComponentInfoParameter(qp) != null)
{
it.remove();
String encryptedParameterValue = getCrypt().encryptUrlSafe(qp.getName());
Url.QueryParameter encryptedParameter
= new Url.QueryParameter(ENCRYPTED_PAGE_COMPONENT_INFO_PARAMETER, encryptedParameterValue);
encryptedUrl.getQueryParameters().add(0, encryptedParameter);
encrypted = true;
break;
}
}
if (encrypted)
{
return encryptedUrl;
}
else
{
return url;
}
}
/**
* Decrypts a {@link Url}. This method should return {@code null} if the URL is not decryptable, or if the
* URL should have been encrypted but was not. Returning {@code null} results in a 404 error.
*
* @param request
* The {@link Request}.
* @param encryptedUrl
* The encrypted {@link Url}.
*
* @return Returns a decrypted {@link Url}.
*/
protected Url decryptUrl(final Request request, final Url encryptedUrl)
{
Url url = decryptEntireUrl(request, encryptedUrl);
if (url == null)
{
if (encryptedUrl.getSegments().size() > 0
&& encryptedUrl.getSegments().get(0).equals(getContext().getNamespace()))
{
/*
* This URL should have been encrypted, but was not. We should refuse to handle this, except when
* there is more than one CryptoMapper installed, and the request was decrypted by some other
* CryptoMapper.
*/
if (request.getOriginalUrl().getSegments().size() > 0
&& request.getOriginalUrl().getSegments().get(0).equals(getContext().getNamespace()))
{
return null;
}
else
{
return encryptedUrl;
}
}
}
if (url == null)
{
url = decryptRequestListenerParameter(request, encryptedUrl);
}
log.debug("Url '{}' has been decrypted to '{}'", encryptedUrl, url);
return url;
}
/**
* Decrypts an entire URL, which was previously encrypted by {@link #encryptEntireUrl(org.apache.wicket.request.Url)}.
* This method should return {@code null} if the URL is not decryptable.
*
* @param request
* The request that was made.
* @param encryptedUrl
* The encrypted URL.
*
* @return A decrypted form of the URL, or {@code null} if the URL is not decryptable.
*/
protected Url decryptEntireUrl(final Request request, final Url encryptedUrl)
{
Url url = new Url(request.getCharset());
List<String> encryptedSegments = encryptedUrl.getSegments();
if (encryptedSegments.isEmpty())
{
return null;
}
/*
* The first encrypted segment contains an encrypted version of the entire plain text url.
*/
String encryptedUrlString = encryptedSegments.get(0);
if (Strings.isEmpty(encryptedUrlString))
{
return null;
}
if (getMarkEncryptedUrls())
{
if (encryptedUrlString.startsWith(ENCRYPTED_URL_MARKER_PREFIX))
{
encryptedUrlString = encryptedUrlString.substring(ENCRYPTED_URL_MARKER_PREFIX.length());
}
else
{
return null;
}
}
String decryptedUrl;
try
{
decryptedUrl = getCrypt().decryptUrlSafe(encryptedUrlString);
}
catch (Exception e)
{
log.error("Error decrypting URL", e);
return null;
}
if (decryptedUrl == null)
{
if (getMarkEncryptedUrls())
{
throw new PageExpiredException("Encrypted URL is no longer decryptable");
}
else
{
return null;
}
}
Url originalUrl = Url.parse(decryptedUrl, request.getCharset());
int originalNumberOfSegments = originalUrl.getSegments().size();
int encryptedNumberOfSegments = encryptedUrl.getSegments().size();
if (originalNumberOfSegments > 0)
{
/*
* This should always be true. Home page URLs are the only ones without
* segments, and we don't encrypt those with this method.
*
* We always add the first segment of the URL, because we encrypt a URL like:
* /path/to/something
* to:
* /encrypted_full/hash/hash
*
* Notice the consistent number of segments. If we applied the following relative URL:
* ../../something
* then the resultant URL would be:
* /something
*
* Hence, the mere existence of the first, encrypted version of complete URL, segment
* tells us that the first segment of the original URL is still to be used.
*/
url.getSegments().add(originalUrl.getSegments().get(0));
}
HashedSegmentGenerator generator = new HashedSegmentGenerator(encryptedUrlString);
int segNo = 1;
for (; segNo < encryptedNumberOfSegments; segNo++)
{
if (segNo >= originalNumberOfSegments)
{
break;
}
String next = generator.next();
String encryptedSegment = encryptedSegments.get(segNo);
if (!next.equals(encryptedSegment))
{
/*
* This segment received from the browser is not the same as the expected segment generated
* by the HashSegmentGenerator. Hence it, and all subsequent segments are considered plain
* text siblings of the original encrypted url.
*/
break;
}
/*
* This segments matches the expected checksum, so we add the corresponding segment from the
* original URL.
*/
url.getSegments().add(originalUrl.getSegments().get(segNo));
}
/*
* Add all remaining segments from the encrypted url as plain text segments.
*/
for (; segNo < encryptedNumberOfSegments; segNo++)
{
// modified or additional segment
url.getSegments().add(encryptedUrl.getSegments().get(segNo));
}
url.getQueryParameters().addAll(originalUrl.getQueryParameters());
// WICKET-4923 additional parameters
url.getQueryParameters().addAll(encryptedUrl.getQueryParameters());
return url;
}
/**
* Decrypts a URL which may contain an encrypted {@link PageComponentInfo} query parameter.
*
* @param request
* The request that was made.
* @param encryptedUrl
* The (potentially) encrypted URL.
*
* @return A decrypted form of the URL.
*/
protected Url decryptRequestListenerParameter(final Request request, Url encryptedUrl)
{
Url url = new Url(encryptedUrl);
url.getQueryParameters().clear();
for (Url.QueryParameter qp : encryptedUrl.getQueryParameters())
{
if (MapperUtils.parsePageComponentInfoParameter(qp) != null)
{
/*
* Plain text request listener parameter found. This should have been encrypted, so we
* refuse to map the request unless the original URL did not include this parameter, which
* case there are likely to be multiple cryptomappers installed.
*/
if (request.getOriginalUrl().getQueryParameter(qp.getName()) == null)
{
url.getQueryParameters().add(qp);
}
else
{
return null;
}
}
else if (ENCRYPTED_PAGE_COMPONENT_INFO_PARAMETER.equals(qp.getName()))
{
String encryptedValue = qp.getValue();
if (Strings.isEmpty(encryptedValue))
{
url.getQueryParameters().add(qp);
}
else
{
String decryptedValue = null;
try
{
decryptedValue = getCrypt().decryptUrlSafe(encryptedValue);
}
catch (Exception e)
{
log.error("Error decrypting encrypted request listener query parameter", e);
}
if (Strings.isEmpty(decryptedValue))
{
url.getQueryParameters().add(qp);
}
else
{
Url.QueryParameter decryptedParamter = new Url.QueryParameter(decryptedValue, "");
url.getQueryParameters().add(0, decryptedParamter);
}
}
}
else
{
url.getQueryParameters().add(qp);
}
}
return url;
}
/**
* A generator of hashed segments.
*/
public static class HashedSegmentGenerator
{
private char[] characters;
private int hash = 0;
public HashedSegmentGenerator(String string)
{
characters = string.toCharArray();
}
/**
* Generate the next segment
*
* @return segment
*/
public String next()
{
char a = characters[Math.abs(hash % characters.length)];
hash++;
char b = characters[Math.abs(hash % characters.length)];
hash++;
char c = characters[Math.abs(hash % characters.length)];
String segment = "" + a + b + c;
hash = hashString(segment);
segment += String.format("%02x", Math.abs(hash % 256));
hash = hashString(segment);
return segment;
}
public int hashString(final String str)
{
int hash = 97;
for (char c : str.toCharArray())
{
int i = c;
hash = 47 * hash + i;
}
return hash;
}
}
}