001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.wicket.core.request.mapper; 018 019import java.util.Iterator; 020import java.util.List; 021import java.util.function.Supplier; 022 023import org.apache.wicket.Application; 024import org.apache.wicket.core.request.handler.RequestSettingRequestHandler; 025import org.apache.wicket.protocol.http.PageExpiredException; 026import org.apache.wicket.request.IRequestHandler; 027import org.apache.wicket.request.IRequestMapper; 028import org.apache.wicket.request.Request; 029import org.apache.wicket.request.Url; 030import org.apache.wicket.request.mapper.IRequestMapperDelegate; 031import org.apache.wicket.request.mapper.info.PageComponentInfo; 032import org.apache.wicket.util.crypt.ICrypt; 033import org.apache.wicket.util.crypt.ICryptFactory; 034import org.apache.wicket.util.lang.Args; 035import org.apache.wicket.util.string.Strings; 036import org.slf4j.Logger; 037import org.slf4j.LoggerFactory; 038 039/** 040 * <p> 041 * A request mapper that encrypts URLs generated by another mapper. This mapper encrypts the segments 042 * and query parameters of URLs starting with {@link IMapperContext#getNamespace()}, and just the 043 * {@link PageComponentInfo} parameter for mounted URLs. 044 * </p> 045 * 046 * <p> 047 * <strong>Important</strong>: for better security it is recommended to use 048 * {@link org.apache.wicket.core.request.mapper.CryptoMapper#CryptoMapper(IRequestMapper, Supplier)} 049 * constructor with {@link org.apache.wicket.util.crypt.ICrypt} implementation that generates a 050 * separate key for each user. {@link org.apache.wicket.core.util.crypt.KeyInSessionSunJceCryptFactory} provides such an 051 * implementation that stores the key in the HTTP session. 052 * </p> 053 * 054 * <p> 055 * This mapper can be mounted before or after mounting other pages, but will only encrypt URLs for 056 * pages mounted before the {@link CryptoMapper}. If required, multiple {@link CryptoMapper}s may be 057 * installed in an {@link Application}. 058 * </p> 059 * 060 * <p> 061 * When encrypting URLs in the Wicket namespace (starting with {@link IMapperContext#getNamespace()}), the entire URL, 062 * including segments and parameters, is encrypted, with the encrypted form stored in the first segment of the encrypted URL. 063 * </p> 064 * 065 * <p> 066 * To be able to handle relative URLs, like for image URLs in a CSS file, checksum segments are appended to the 067 * encrypted URL until the encrypted URL has the same number of segments as the original URL had. 068 * Each checksum segment has a precise 5 character value, calculated using a checksum. This helps in calculating 069 * the relative distance from the original URL. When a URL is returned by the browser, we iterate through these 070 * checksummed placeholder URL segments. If the segment matches the expected checksum, then the segment is deemed 071 * to be the corresponding segment in the original URL. If the segment does not match the expected checksum, then 072 * the segment is deemed a plain text sibling of the corresponding segment in the original URL, and all subsequent 073 * segments are considered plain text children of the current segment. 074 * </p> 075 * 076 * <p> 077 * When encrypting mounted URLs, we look for the {@link PageComponentInfo} parameter, and encrypt only that parameter. 078 * </p> 079 * 080 * <p> 081 * {@link CryptoMapper} can be configured to mark encrypted URLs as encrypted, and throw a {@link PageExpiredException} 082 * exception if a encrypted URL cannot be decrypted. This can occur when using {@code KeyInSessionSunJceCryptFactory}, and 083 * the session has expired. 084 * </p> 085 * 086 * @author igor.vaynberg 087 * @author Jesse Long 088 * @author svenmeier 089 * @see org.apache.wicket.settings.SecuritySettings#setCryptFactory(org.apache.wicket.util.crypt.ICryptFactory) 090 * @see org.apache.wicket.core.util.crypt.KeyInSessionSunJceCryptFactory 091 * @see org.apache.wicket.util.crypt.SunJceCrypt 092 */ 093public class CryptoMapper implements IRequestMapperDelegate 094{ 095 private static final Logger log = LoggerFactory.getLogger(CryptoMapper.class); 096 097 /** 098 * Name of the parameter which contains encrypted page component info. 099 */ 100 private static final String ENCRYPTED_PAGE_COMPONENT_INFO_PARAMETER = "wicket-crypt"; 101 102 private static final String ENCRYPTED_URL_MARKER_PREFIX = "crypt."; 103 104 private final IRequestMapper wrappedMapper; 105 private final Supplier<ICrypt> cryptProvider; 106 107 /** 108 * Whether or not to mark encrypted URLs as encrypted. 109 */ 110 private boolean markEncryptedUrls = false; 111 112 /** 113 * Encrypt with {@link org.apache.wicket.settings.SecuritySettings#getCryptFactory()}. 114 * <p> 115 * <strong>Important</strong>: Encryption is done with {@link org.apache.wicket.settings.SecuritySettings#DEFAULT_ENCRYPTION_KEY} if you haven't 116 * configured an alternative {@link ICryptFactory}. For better security it is recommended to use 117 * {@link CryptoMapper#CryptoMapper(IRequestMapper, Supplier)} with a specific {@link ICrypt} implementation 118 * that generates a separate key for each user. 119 * {@link org.apache.wicket.core.util.crypt.KeyInSessionSunJceCryptFactory} provides such an implementation that stores the 120 * key in the HTTP session. 121 * </p> 122 * 123 * @param wrappedMapper 124 * the non-crypted request mapper 125 * @param application 126 * the current application 127 * @see org.apache.wicket.util.crypt.SunJceCrypt 128 */ 129 public CryptoMapper(final IRequestMapper wrappedMapper, final Application application) 130 { 131 this(wrappedMapper, () -> application.getSecuritySettings().getCryptFactory().newCrypt()); 132 } 133 134 /** 135 * Construct. 136 * 137 * @param wrappedMapper 138 * the non-crypted request mapper 139 * @param cryptProvider 140 * the custom crypt provider 141 */ 142 public CryptoMapper(final IRequestMapper wrappedMapper, final Supplier<ICrypt> cryptProvider) 143 { 144 this.wrappedMapper = Args.notNull(wrappedMapper, "wrappedMapper"); 145 this.cryptProvider = Args.notNull(cryptProvider, "cryptProvider"); 146 } 147 148 /** 149 * Whether or not to mark encrypted URLs as encrypted. If set, a {@link PageExpiredException} is thrown when 150 * a encrypted URL can no longer be decrypted. 151 * 152 * @return whether or not to mark encrypted URLs as encrypted. 153 */ 154 public boolean getMarkEncryptedUrls() 155 { 156 return markEncryptedUrls; 157 } 158 159 /** 160 * Sets whether or not to mark encrypted URLs as encrypted. If set, a {@link PageExpiredException} is thrown when 161 * a encrypted URL can no longer be decrypted. 162 * 163 * @param markEncryptedUrls 164 * whether or not to mark encrypted URLs as encrypted. 165 * 166 * @return {@code this}, for chaining. 167 */ 168 public CryptoMapper setMarkEncryptedUrls(boolean markEncryptedUrls) 169 { 170 this.markEncryptedUrls = markEncryptedUrls; 171 return this; 172 } 173 174 /** 175 * {@inheritDoc} 176 * <p> 177 * This implementation decrypts the URL and passes the decrypted URL to the wrapped mapper. 178 * </p> 179 * @param request 180 * The request for which to get a compatibility score. 181 * 182 * @return The compatibility score. 183 */ 184 @Override 185 public int getCompatibilityScore(final Request request) 186 { 187 Url decryptedUrl = decryptUrl(request, request.getUrl()); 188 189 if (decryptedUrl == null) 190 { 191 return 0; 192 } 193 194 Request decryptedRequest = request.cloneWithUrl(decryptedUrl); 195 196 return wrappedMapper.getCompatibilityScore(decryptedRequest); 197 } 198 199 @Override 200 public Url mapHandler(final IRequestHandler requestHandler) 201 { 202 final Url url = wrappedMapper.mapHandler(requestHandler); 203 204 if (url == null) 205 { 206 return null; 207 } 208 209 if (url.isFull()) 210 { 211 // do not encrypt full urls 212 return url; 213 } 214 215 return encryptUrl(url); 216 } 217 218 @Override 219 public IRequestHandler mapRequest(final Request request) 220 { 221 Url url = decryptUrl(request, request.getUrl()); 222 223 if (url == null) 224 { 225 return null; 226 } 227 228 Request decryptedRequest = request.cloneWithUrl(url); 229 230 IRequestHandler handler = wrappedMapper.mapRequest(decryptedRequest); 231 232 if (handler != null) 233 { 234 handler = new RequestSettingRequestHandler(decryptedRequest, handler); 235 } 236 237 return handler; 238 } 239 240 /** 241 * @return the {@link ICrypt} implementation that may be used to encrypt/decrypt {@link Url}'s 242 * segments and/or query string 243 */ 244 protected final ICrypt getCrypt() 245 { 246 return cryptProvider.get(); 247 } 248 249 /** 250 * @return the wrapped root request mapper 251 */ 252 @Override 253 public final IRequestMapper getDelegateMapper() 254 { 255 return wrappedMapper; 256 } 257 258 /** 259 * Returns the applications {@link IMapperContext}. 260 * 261 * @return The applications {@link IMapperContext}. 262 */ 263 protected IMapperContext getContext() 264 { 265 return Application.get().getMapperContext(); 266 } 267 268 /** 269 * Encrypts a URL. This method should return a new, encrypted instance of the URL. If the URL starts with {@code /wicket/}, 270 * the entire URL is encrypted. 271 * 272 * @param url 273 * The URL to encrypt. 274 * 275 * @return A new, encrypted version of the URL. 276 */ 277 protected Url encryptUrl(final Url url) 278 { 279 if (url.getSegments().size() > 0 280 && url.getSegments().get(0).equals(getContext().getNamespace())) 281 { 282 return encryptEntireUrl(url); 283 } 284 else 285 { 286 return encryptRequestListenerParameter(url); 287 } 288 } 289 290 /** 291 * Encrypts an entire URL, segments and query parameters. 292 * 293 * @param url 294 * The URL to encrypt. 295 * 296 * @return An encrypted form of the URL. 297 */ 298 protected Url encryptEntireUrl(final Url url) 299 { 300 String encryptedUrlString = getCrypt().encryptUrlSafe(url.toString()); 301 302 Url encryptedUrl = new Url(url.getCharset()); 303 304 if (getMarkEncryptedUrls()) 305 { 306 encryptedUrl.getSegments().add(ENCRYPTED_URL_MARKER_PREFIX + encryptedUrlString); 307 } 308 else 309 { 310 encryptedUrl.getSegments().add(encryptedUrlString); 311 } 312 313 int numberOfSegments = url.getSegments().size() - 1; 314 HashedSegmentGenerator generator = new HashedSegmentGenerator(encryptedUrlString); 315 for (int segNo = 0; segNo < numberOfSegments; segNo++) 316 { 317 encryptedUrl.getSegments().add(generator.next()); 318 } 319 return encryptedUrl; 320 } 321 322 /** 323 * Encrypts the {@link PageComponentInfo} query parameter in the URL, if any is found. 324 * 325 * @param url 326 * The URL to encrypt. 327 * 328 * @return An encrypted form of the URL. 329 */ 330 protected Url encryptRequestListenerParameter(final Url url) 331 { 332 Url encryptedUrl = new Url(url); 333 boolean encrypted = false; 334 335 for (Iterator<Url.QueryParameter> it = encryptedUrl.getQueryParameters().iterator(); it.hasNext();) 336 { 337 Url.QueryParameter qp = it.next(); 338 339 if (MapperUtils.parsePageComponentInfoParameter(qp) != null) 340 { 341 it.remove(); 342 String encryptedParameterValue = getCrypt().encryptUrlSafe(qp.getName()); 343 Url.QueryParameter encryptedParameter 344 = new Url.QueryParameter(ENCRYPTED_PAGE_COMPONENT_INFO_PARAMETER, encryptedParameterValue); 345 encryptedUrl.getQueryParameters().add(0, encryptedParameter); 346 encrypted = true; 347 break; 348 } 349 } 350 351 if (encrypted) 352 { 353 return encryptedUrl; 354 } 355 else 356 { 357 return url; 358 } 359 } 360 361 /** 362 * Decrypts a {@link Url}. This method should return {@code null} if the URL is not decryptable, or if the 363 * URL should have been encrypted but was not. Returning {@code null} results in a 404 error. 364 * 365 * @param request 366 * The {@link Request}. 367 * @param encryptedUrl 368 * The encrypted {@link Url}. 369 * 370 * @return Returns a decrypted {@link Url}. 371 */ 372 protected Url decryptUrl(final Request request, final Url encryptedUrl) 373 { 374 Url url = decryptEntireUrl(request, encryptedUrl); 375 376 if (url == null) 377 { 378 if (encryptedUrl.getSegments().size() > 0 379 && encryptedUrl.getSegments().get(0).equals(getContext().getNamespace())) 380 { 381 /* 382 * This URL should have been encrypted, but was not. We should refuse to handle this, except when 383 * there is more than one CryptoMapper installed, and the request was decrypted by some other 384 * CryptoMapper. 385 */ 386 if (request.getOriginalUrl().getSegments().size() > 0 387 && request.getOriginalUrl().getSegments().get(0).equals(getContext().getNamespace())) 388 { 389 return null; 390 } 391 else 392 { 393 return encryptedUrl; 394 } 395 } 396 } 397 398 if (url == null) 399 { 400 url = decryptRequestListenerParameter(request, encryptedUrl); 401 } 402 403 log.debug("Url '{}' has been decrypted to '{}'", encryptedUrl, url); 404 405 return url; 406 } 407 408 /** 409 * Decrypts an entire URL, which was previously encrypted by {@link #encryptEntireUrl(org.apache.wicket.request.Url)}. 410 * This method should return {@code null} if the URL is not decryptable. 411 * 412 * @param request 413 * The request that was made. 414 * @param encryptedUrl 415 * The encrypted URL. 416 * 417 * @return A decrypted form of the URL, or {@code null} if the URL is not decryptable. 418 */ 419 protected Url decryptEntireUrl(final Request request, final Url encryptedUrl) 420 { 421 Url url = new Url(request.getCharset()); 422 423 List<String> encryptedSegments = encryptedUrl.getSegments(); 424 425 if (encryptedSegments.isEmpty()) 426 { 427 return null; 428 } 429 430 /* 431 * The first encrypted segment contains an encrypted version of the entire plain text url. 432 */ 433 String encryptedUrlString = encryptedSegments.get(0); 434 if (Strings.isEmpty(encryptedUrlString)) 435 { 436 return null; 437 } 438 439 if (getMarkEncryptedUrls()) 440 { 441 if (encryptedUrlString.startsWith(ENCRYPTED_URL_MARKER_PREFIX)) 442 { 443 encryptedUrlString = encryptedUrlString.substring(ENCRYPTED_URL_MARKER_PREFIX.length()); 444 } 445 else 446 { 447 return null; 448 } 449 } 450 451 String decryptedUrl; 452 try 453 { 454 decryptedUrl = getCrypt().decryptUrlSafe(encryptedUrlString); 455 } 456 catch (Exception e) 457 { 458 log.error("Error decrypting URL", e); 459 return null; 460 } 461 462 if (decryptedUrl == null) 463 { 464 if (getMarkEncryptedUrls()) 465 { 466 throw new PageExpiredException("Encrypted URL is no longer decryptable"); 467 } 468 else 469 { 470 return null; 471 } 472 } 473 474 Url originalUrl = Url.parse(decryptedUrl, request.getCharset()); 475 476 int originalNumberOfSegments = originalUrl.getSegments().size(); 477 int encryptedNumberOfSegments = encryptedUrl.getSegments().size(); 478 479 if (originalNumberOfSegments > 0) 480 { 481 /* 482 * This should always be true. Home page URLs are the only ones without 483 * segments, and we don't encrypt those with this method. 484 * 485 * We always add the first segment of the URL, because we encrypt a URL like: 486 * /path/to/something 487 * to: 488 * /encrypted_full/hash/hash 489 * 490 * Notice the consistent number of segments. If we applied the following relative URL: 491 * ../../something 492 * then the resultant URL would be: 493 * /something 494 * 495 * Hence, the mere existence of the first, encrypted version of complete URL, segment 496 * tells us that the first segment of the original URL is still to be used. 497 */ 498 url.getSegments().add(originalUrl.getSegments().get(0)); 499 } 500 501 HashedSegmentGenerator generator = new HashedSegmentGenerator(encryptedUrlString); 502 int segNo = 1; 503 for (; segNo < encryptedNumberOfSegments; segNo++) 504 { 505 if (segNo >= originalNumberOfSegments) 506 { 507 break; 508 } 509 510 String next = generator.next(); 511 String encryptedSegment = encryptedSegments.get(segNo); 512 if (!next.equals(encryptedSegment)) 513 { 514 /* 515 * This segment received from the browser is not the same as the expected segment generated 516 * by the HashSegmentGenerator. Hence it, and all subsequent segments are considered plain 517 * text siblings of the original encrypted url. 518 */ 519 break; 520 } 521 522 /* 523 * This segments matches the expected checksum, so we add the corresponding segment from the 524 * original URL. 525 */ 526 url.getSegments().add(originalUrl.getSegments().get(segNo)); 527 } 528 /* 529 * Add all remaining segments from the encrypted url as plain text segments. 530 */ 531 for (; segNo < encryptedNumberOfSegments; segNo++) 532 { 533 // modified or additional segment 534 url.getSegments().add(encryptedUrl.getSegments().get(segNo)); 535 } 536 537 url.getQueryParameters().addAll(originalUrl.getQueryParameters()); 538 // WICKET-4923 additional parameters 539 url.getQueryParameters().addAll(encryptedUrl.getQueryParameters()); 540 541 return url; 542 } 543 544 /** 545 * Decrypts a URL which may contain an encrypted {@link PageComponentInfo} query parameter. 546 * 547 * @param request 548 * The request that was made. 549 * @param encryptedUrl 550 * The (potentially) encrypted URL. 551 * 552 * @return A decrypted form of the URL. 553 */ 554 protected Url decryptRequestListenerParameter(final Request request, Url encryptedUrl) 555 { 556 Url url = new Url(encryptedUrl); 557 558 url.getQueryParameters().clear(); 559 560 for (Url.QueryParameter qp : encryptedUrl.getQueryParameters()) 561 { 562 if (MapperUtils.parsePageComponentInfoParameter(qp) != null) 563 { 564 /* 565 * Plain text request listener parameter found. This should have been encrypted, so we 566 * refuse to map the request unless the original URL did not include this parameter, which 567 * case there are likely to be multiple cryptomappers installed. 568 */ 569 if (request.getOriginalUrl().getQueryParameter(qp.getName()) == null) 570 { 571 url.getQueryParameters().add(qp); 572 } 573 else 574 { 575 return null; 576 } 577 } 578 else if (ENCRYPTED_PAGE_COMPONENT_INFO_PARAMETER.equals(qp.getName())) 579 { 580 String encryptedValue = qp.getValue(); 581 582 if (Strings.isEmpty(encryptedValue)) 583 { 584 url.getQueryParameters().add(qp); 585 } 586 else 587 { 588 String decryptedValue = null; 589 590 try 591 { 592 decryptedValue = getCrypt().decryptUrlSafe(encryptedValue); 593 } 594 catch (Exception e) 595 { 596 log.error("Error decrypting encrypted request listener query parameter", e); 597 } 598 599 if (Strings.isEmpty(decryptedValue)) 600 { 601 url.getQueryParameters().add(qp); 602 } 603 else 604 { 605 Url.QueryParameter decryptedParamter = new Url.QueryParameter(decryptedValue, ""); 606 url.getQueryParameters().add(0, decryptedParamter); 607 } 608 } 609 } 610 else 611 { 612 url.getQueryParameters().add(qp); 613 } 614 } 615 616 return url; 617 } 618 619 /** 620 * A generator of hashed segments. 621 */ 622 public static class HashedSegmentGenerator 623 { 624 private char[] characters; 625 626 private int hash = 0; 627 628 public HashedSegmentGenerator(String string) 629 { 630 characters = string.toCharArray(); 631 } 632 633 /** 634 * Generate the next segment 635 * 636 * @return segment 637 */ 638 public String next() 639 { 640 char a = characters[Math.abs(hash % characters.length)]; 641 hash++; 642 char b = characters[Math.abs(hash % characters.length)]; 643 hash++; 644 char c = characters[Math.abs(hash % characters.length)]; 645 646 String segment = "" + a + b + c; 647 hash = hashString(segment); 648 649 segment += String.format("%02x", Math.abs(hash % 256)); 650 hash = hashString(segment); 651 652 return segment; 653 } 654 655 public int hashString(final String str) 656 { 657 int hash = 97; 658 659 for (char c : str.toCharArray()) 660 { 661 int i = c; 662 hash = 47 * hash + i; 663 } 664 665 return hash; 666 } 667 } 668}