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>: For better security it is recommended to use 116 * {@link CryptoMapper#CryptoMapper(IRequestMapper, Supplier)} with a specific {@link ICrypt} implementation 117 * that generates a separate key for each user. 118 * {@link org.apache.wicket.core.util.crypt.KeyInSessionSunJceCryptFactory} provides such an implementation that stores the 119 * key in the HTTP session. 120 * </p> 121 * 122 * @param wrappedMapper 123 * the non-crypted request mapper 124 * @param application 125 * the current application 126 * @see org.apache.wicket.util.crypt.SunJceCrypt 127 */ 128 public CryptoMapper(final IRequestMapper wrappedMapper, final Application application) 129 { 130 this(wrappedMapper, () -> application.getSecuritySettings().getCryptFactory().newCrypt()); 131 } 132 133 /** 134 * Construct. 135 * 136 * @param wrappedMapper 137 * the non-crypted request mapper 138 * @param cryptProvider 139 * the custom crypt provider 140 */ 141 public CryptoMapper(final IRequestMapper wrappedMapper, final Supplier<ICrypt> cryptProvider) 142 { 143 this.wrappedMapper = Args.notNull(wrappedMapper, "wrappedMapper"); 144 this.cryptProvider = Args.notNull(cryptProvider, "cryptProvider"); 145 } 146 147 /** 148 * Whether or not to mark encrypted URLs as encrypted. If set, a {@link PageExpiredException} is thrown when 149 * a encrypted URL can no longer be decrypted. 150 * 151 * @return whether or not to mark encrypted URLs as encrypted. 152 */ 153 public boolean getMarkEncryptedUrls() 154 { 155 return markEncryptedUrls; 156 } 157 158 /** 159 * Sets whether or not to mark encrypted URLs as encrypted. If set, a {@link PageExpiredException} is thrown when 160 * a encrypted URL can no longer be decrypted. 161 * 162 * @param markEncryptedUrls 163 * whether or not to mark encrypted URLs as encrypted. 164 * 165 * @return {@code this}, for chaining. 166 */ 167 public CryptoMapper setMarkEncryptedUrls(boolean markEncryptedUrls) 168 { 169 this.markEncryptedUrls = markEncryptedUrls; 170 return this; 171 } 172 173 /** 174 * {@inheritDoc} 175 * <p> 176 * This implementation decrypts the URL and passes the decrypted URL to the wrapped mapper. 177 * </p> 178 * @param request 179 * The request for which to get a compatibility score. 180 * 181 * @return The compatibility score. 182 */ 183 @Override 184 public int getCompatibilityScore(final Request request) 185 { 186 Url decryptedUrl = decryptUrl(request, request.getUrl()); 187 188 if (decryptedUrl == null) 189 { 190 return 0; 191 } 192 193 Request decryptedRequest = request.cloneWithUrl(decryptedUrl); 194 195 return wrappedMapper.getCompatibilityScore(decryptedRequest); 196 } 197 198 @Override 199 public Url mapHandler(final IRequestHandler requestHandler) 200 { 201 final Url url = wrappedMapper.mapHandler(requestHandler); 202 203 if (url == null) 204 { 205 return null; 206 } 207 208 if (url.isFull()) 209 { 210 // do not encrypt full urls 211 return url; 212 } 213 214 return encryptUrl(url); 215 } 216 217 @Override 218 public IRequestHandler mapRequest(final Request request) 219 { 220 Url url = decryptUrl(request, request.getUrl()); 221 222 if (url == null) 223 { 224 return null; 225 } 226 227 Request decryptedRequest = request.cloneWithUrl(url); 228 229 IRequestHandler handler = wrappedMapper.mapRequest(decryptedRequest); 230 231 if (handler != null) 232 { 233 handler = new RequestSettingRequestHandler(decryptedRequest, handler); 234 } 235 236 return handler; 237 } 238 239 /** 240 * @return the {@link ICrypt} implementation that may be used to encrypt/decrypt {@link Url}'s 241 * segments and/or query string 242 */ 243 protected final ICrypt getCrypt() 244 { 245 return cryptProvider.get(); 246 } 247 248 /** 249 * @return the wrapped root request mapper 250 */ 251 @Override 252 public final IRequestMapper getDelegateMapper() 253 { 254 return wrappedMapper; 255 } 256 257 /** 258 * Returns the applications {@link IMapperContext}. 259 * 260 * @return The applications {@link IMapperContext}. 261 */ 262 protected IMapperContext getContext() 263 { 264 return Application.get().getMapperContext(); 265 } 266 267 /** 268 * Encrypts a URL. This method should return a new, encrypted instance of the URL. If the URL starts with {@code /wicket/}, 269 * the entire URL is encrypted. 270 * 271 * @param url 272 * The URL to encrypt. 273 * 274 * @return A new, encrypted version of the URL. 275 */ 276 protected Url encryptUrl(final Url url) 277 { 278 if (url.getSegments().size() > 0 279 && url.getSegments().get(0).equals(getContext().getNamespace())) 280 { 281 return encryptEntireUrl(url); 282 } 283 else 284 { 285 return encryptRequestListenerParameter(url); 286 } 287 } 288 289 /** 290 * Encrypts an entire URL, segments and query parameters. 291 * 292 * @param url 293 * The URL to encrypt. 294 * 295 * @return An encrypted form of the URL. 296 */ 297 protected Url encryptEntireUrl(final Url url) 298 { 299 String encryptedUrlString = getCrypt().encryptUrlSafe(url.toString()); 300 301 Url encryptedUrl = new Url(url.getCharset()); 302 303 if (getMarkEncryptedUrls()) 304 { 305 encryptedUrl.getSegments().add(ENCRYPTED_URL_MARKER_PREFIX + encryptedUrlString); 306 } 307 else 308 { 309 encryptedUrl.getSegments().add(encryptedUrlString); 310 } 311 312 int numberOfSegments = url.getSegments().size() - 1; 313 HashedSegmentGenerator generator = new HashedSegmentGenerator(encryptedUrlString); 314 for (int segNo = 0; segNo < numberOfSegments; segNo++) 315 { 316 encryptedUrl.getSegments().add(generator.next()); 317 } 318 return encryptedUrl; 319 } 320 321 /** 322 * Encrypts the {@link PageComponentInfo} query parameter in the URL, if any is found. 323 * 324 * @param url 325 * The URL to encrypt. 326 * 327 * @return An encrypted form of the URL. 328 */ 329 protected Url encryptRequestListenerParameter(final Url url) 330 { 331 Url encryptedUrl = new Url(url); 332 boolean encrypted = false; 333 334 for (Iterator<Url.QueryParameter> it = encryptedUrl.getQueryParameters().iterator(); it.hasNext();) 335 { 336 Url.QueryParameter qp = it.next(); 337 338 if (MapperUtils.parsePageComponentInfoParameter(qp) != null) 339 { 340 it.remove(); 341 String encryptedParameterValue = getCrypt().encryptUrlSafe(qp.getName()); 342 Url.QueryParameter encryptedParameter 343 = new Url.QueryParameter(ENCRYPTED_PAGE_COMPONENT_INFO_PARAMETER, encryptedParameterValue); 344 encryptedUrl.getQueryParameters().add(0, encryptedParameter); 345 encrypted = true; 346 break; 347 } 348 } 349 350 if (encrypted) 351 { 352 return encryptedUrl; 353 } 354 else 355 { 356 return url; 357 } 358 } 359 360 /** 361 * Decrypts a {@link Url}. This method should return {@code null} if the URL is not decryptable, or if the 362 * URL should have been encrypted but was not. Returning {@code null} results in a 404 error. 363 * 364 * @param request 365 * The {@link Request}. 366 * @param encryptedUrl 367 * The encrypted {@link Url}. 368 * 369 * @return Returns a decrypted {@link Url}. 370 */ 371 protected Url decryptUrl(final Request request, final Url encryptedUrl) 372 { 373 Url url = decryptEntireUrl(request, encryptedUrl); 374 375 if (url == null) 376 { 377 if (encryptedUrl.getSegments().size() > 0 378 && encryptedUrl.getSegments().get(0).equals(getContext().getNamespace())) 379 { 380 /* 381 * This URL should have been encrypted, but was not. We should refuse to handle this, except when 382 * there is more than one CryptoMapper installed, and the request was decrypted by some other 383 * CryptoMapper. 384 */ 385 if (request.getOriginalUrl().getSegments().size() > 0 386 && request.getOriginalUrl().getSegments().get(0).equals(getContext().getNamespace())) 387 { 388 return null; 389 } 390 else 391 { 392 return encryptedUrl; 393 } 394 } 395 } 396 397 if (url == null) 398 { 399 url = decryptRequestListenerParameter(request, encryptedUrl); 400 } 401 402 log.debug("Url '{}' has been decrypted to '{}'", encryptedUrl, url); 403 404 return url; 405 } 406 407 /** 408 * Decrypts an entire URL, which was previously encrypted by {@link #encryptEntireUrl(org.apache.wicket.request.Url)}. 409 * This method should return {@code null} if the URL is not decryptable. 410 * 411 * @param request 412 * The request that was made. 413 * @param encryptedUrl 414 * The encrypted URL. 415 * 416 * @return A decrypted form of the URL, or {@code null} if the URL is not decryptable. 417 */ 418 protected Url decryptEntireUrl(final Request request, final Url encryptedUrl) 419 { 420 Url url = new Url(request.getCharset()); 421 422 List<String> encryptedSegments = encryptedUrl.getSegments(); 423 424 if (encryptedSegments.isEmpty()) 425 { 426 return null; 427 } 428 429 /* 430 * The first encrypted segment contains an encrypted version of the entire plain text url. 431 */ 432 String encryptedUrlString = encryptedSegments.get(0); 433 if (Strings.isEmpty(encryptedUrlString)) 434 { 435 return null; 436 } 437 438 if (getMarkEncryptedUrls()) 439 { 440 if (encryptedUrlString.startsWith(ENCRYPTED_URL_MARKER_PREFIX)) 441 { 442 encryptedUrlString = encryptedUrlString.substring(ENCRYPTED_URL_MARKER_PREFIX.length()); 443 } 444 else 445 { 446 return null; 447 } 448 } 449 450 String decryptedUrl; 451 try 452 { 453 decryptedUrl = getCrypt().decryptUrlSafe(encryptedUrlString); 454 } 455 catch (Exception e) 456 { 457 log.error("Error decrypting URL", e); 458 return null; 459 } 460 461 if (decryptedUrl == null) 462 { 463 if (getMarkEncryptedUrls()) 464 { 465 throw new PageExpiredException("Encrypted URL is no longer decryptable"); 466 } 467 else 468 { 469 return null; 470 } 471 } 472 473 Url originalUrl = Url.parse(decryptedUrl, request.getCharset()); 474 475 int originalNumberOfSegments = originalUrl.getSegments().size(); 476 int encryptedNumberOfSegments = encryptedUrl.getSegments().size(); 477 478 if (originalNumberOfSegments > 0) 479 { 480 /* 481 * This should always be true. Home page URLs are the only ones without 482 * segments, and we don't encrypt those with this method. 483 * 484 * We always add the first segment of the URL, because we encrypt a URL like: 485 * /path/to/something 486 * to: 487 * /encrypted_full/hash/hash 488 * 489 * Notice the consistent number of segments. If we applied the following relative URL: 490 * ../../something 491 * then the resultant URL would be: 492 * /something 493 * 494 * Hence, the mere existence of the first, encrypted version of complete URL, segment 495 * tells us that the first segment of the original URL is still to be used. 496 */ 497 url.getSegments().add(originalUrl.getSegments().get(0)); 498 } 499 500 HashedSegmentGenerator generator = new HashedSegmentGenerator(encryptedUrlString); 501 int segNo = 1; 502 for (; segNo < encryptedNumberOfSegments; segNo++) 503 { 504 if (segNo >= originalNumberOfSegments) 505 { 506 break; 507 } 508 509 String next = generator.next(); 510 String encryptedSegment = encryptedSegments.get(segNo); 511 if (!next.equals(encryptedSegment)) 512 { 513 /* 514 * This segment received from the browser is not the same as the expected segment generated 515 * by the HashSegmentGenerator. Hence it, and all subsequent segments are considered plain 516 * text siblings of the original encrypted url. 517 */ 518 break; 519 } 520 521 /* 522 * This segments matches the expected checksum, so we add the corresponding segment from the 523 * original URL. 524 */ 525 url.getSegments().add(originalUrl.getSegments().get(segNo)); 526 } 527 /* 528 * Add all remaining segments from the encrypted url as plain text segments. 529 */ 530 for (; segNo < encryptedNumberOfSegments; segNo++) 531 { 532 // modified or additional segment 533 url.getSegments().add(encryptedUrl.getSegments().get(segNo)); 534 } 535 536 url.getQueryParameters().addAll(originalUrl.getQueryParameters()); 537 // WICKET-4923 additional parameters 538 url.getQueryParameters().addAll(encryptedUrl.getQueryParameters()); 539 540 return url; 541 } 542 543 /** 544 * Decrypts a URL which may contain an encrypted {@link PageComponentInfo} query parameter. 545 * 546 * @param request 547 * The request that was made. 548 * @param encryptedUrl 549 * The (potentially) encrypted URL. 550 * 551 * @return A decrypted form of the URL. 552 */ 553 protected Url decryptRequestListenerParameter(final Request request, Url encryptedUrl) 554 { 555 Url url = new Url(encryptedUrl); 556 557 url.getQueryParameters().clear(); 558 559 for (Url.QueryParameter qp : encryptedUrl.getQueryParameters()) 560 { 561 if (MapperUtils.parsePageComponentInfoParameter(qp) != null) 562 { 563 /* 564 * Plain text request listener parameter found. This should have been encrypted, so we 565 * refuse to map the request unless the original URL did not include this parameter, which 566 * case there are likely to be multiple cryptomappers installed. 567 */ 568 if (request.getOriginalUrl().getQueryParameter(qp.getName()) == null) 569 { 570 url.getQueryParameters().add(qp); 571 } 572 else 573 { 574 return null; 575 } 576 } 577 else if (ENCRYPTED_PAGE_COMPONENT_INFO_PARAMETER.equals(qp.getName())) 578 { 579 String encryptedValue = qp.getValue(); 580 581 if (Strings.isEmpty(encryptedValue)) 582 { 583 url.getQueryParameters().add(qp); 584 } 585 else 586 { 587 String decryptedValue = null; 588 589 try 590 { 591 decryptedValue = getCrypt().decryptUrlSafe(encryptedValue); 592 } 593 catch (Exception e) 594 { 595 log.error("Error decrypting encrypted request listener query parameter", e); 596 } 597 598 if (Strings.isEmpty(decryptedValue)) 599 { 600 url.getQueryParameters().add(qp); 601 } 602 else 603 { 604 Url.QueryParameter decryptedParamter = new Url.QueryParameter(decryptedValue, ""); 605 url.getQueryParameters().add(0, decryptedParamter); 606 } 607 } 608 } 609 else 610 { 611 url.getQueryParameters().add(qp); 612 } 613 } 614 615 return url; 616 } 617 618 /** 619 * A generator of hashed segments. 620 */ 621 public static class HashedSegmentGenerator 622 { 623 private char[] characters; 624 625 private int hash = 0; 626 627 public HashedSegmentGenerator(String string) 628 { 629 characters = string.toCharArray(); 630 } 631 632 /** 633 * Generate the next segment 634 * 635 * @return segment 636 */ 637 public String next() 638 { 639 char a = characters[Math.abs(hash % characters.length)]; 640 hash++; 641 char b = characters[Math.abs(hash % characters.length)]; 642 hash++; 643 char c = characters[Math.abs(hash % characters.length)]; 644 645 String segment = "" + a + b + c; 646 hash = hashString(segment); 647 648 segment += String.format("%02x", Math.abs(hash % 256)); 649 hash = hashString(segment); 650 651 return segment; 652 } 653 654 public int hashString(final String str) 655 { 656 int hash = 97; 657 658 for (char c : str.toCharArray()) 659 { 660 int i = c; 661 hash = 47 * hash + i; 662 } 663 664 return hash; 665 } 666 } 667}