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}