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}