001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.apache.shiro.web.servlet;
020
021import org.apache.shiro.lang.util.StringUtils;
022import org.owasp.encoder.Encode;
023import org.slf4j.Logger;
024import org.slf4j.LoggerFactory;
025
026import javax.servlet.http.HttpServletRequest;
027import javax.servlet.http.HttpServletResponse;
028import java.text.DateFormat;
029import java.text.SimpleDateFormat;
030import java.util.Calendar;
031import java.util.Date;
032import java.util.Locale;
033import java.util.TimeZone;
034
035/**
036 * Default {@link Cookie Cookie} implementation.  'HttpOnly' is supported out of the box, even on
037 * Servlet {@code 2.4} and {@code 2.5} container implementations, using raw header writing logic and not
038 * {@link javax.servlet.http.Cookie javax.servlet.http.Cookie} objects (which only has 'HttpOnly' support in Servlet
039 * {@code 2.6} specifications and above).
040 *
041 * @since 1.0
042 */
043@SuppressWarnings({"checkstyle:MethodCount", "checkstyle:ParameterNumber"})
044public class SimpleCookie implements Cookie {
045
046    /**
047     * {@code -1}, indicating the cookie should expire when the browser closes.
048     */
049    public static final int DEFAULT_MAX_AGE = -1;
050
051    /**
052     * {@code -1} indicating that no version property should be set on the cookie.
053     */
054    public static final int DEFAULT_VERSION = -1;
055
056    //These constants are protected on purpose so that the test case can use them
057    protected static final String NAME_VALUE_DELIMITER = "=";
058    protected static final String ATTRIBUTE_DELIMITER = "; ";
059    //1 day = 86,400,000 milliseconds
060    protected static final long DAY_MILLIS = 86400000;
061    protected static final String GMT_TIME_ZONE_ID = "GMT";
062    protected static final String COOKIE_DATE_FORMAT_STRING = "EEE, dd-MMM-yyyy HH:mm:ss z";
063
064    protected static final String COOKIE_HEADER_NAME = "Set-Cookie";
065    protected static final String PATH_ATTRIBUTE_NAME = "Path";
066    protected static final String EXPIRES_ATTRIBUTE_NAME = "Expires";
067    protected static final String MAXAGE_ATTRIBUTE_NAME = "Max-Age";
068    protected static final String DOMAIN_ATTRIBUTE_NAME = "Domain";
069    protected static final String VERSION_ATTRIBUTE_NAME = "Version";
070    protected static final String COMMENT_ATTRIBUTE_NAME = "Comment";
071    protected static final String SECURE_ATTRIBUTE_NAME = "Secure";
072    protected static final String HTTP_ONLY_ATTRIBUTE_NAME = "HttpOnly";
073    protected static final String SAME_SITE_ATTRIBUTE_NAME = "SameSite";
074
075    private static final Logger LOGGER = LoggerFactory.getLogger(SimpleCookie.class);
076
077    private String name;
078    private String value;
079    private String comment;
080    private String domain;
081    private String path;
082    private int maxAge;
083    private int version;
084    private boolean secure;
085    private boolean httpOnly;
086    private SameSiteOptions sameSite;
087
088    public SimpleCookie() {
089        this.maxAge = DEFAULT_MAX_AGE;
090        this.version = DEFAULT_VERSION;
091        //most of the cookies ever used by Shiro should be as secure as possible.
092        this.httpOnly = true;
093        this.sameSite = SameSiteOptions.LAX;
094    }
095
096    public SimpleCookie(String name) {
097        this();
098        this.name = name;
099    }
100
101    public SimpleCookie(Cookie cookie) {
102        this.name = cookie.getName();
103        this.value = cookie.getValue();
104        this.comment = cookie.getComment();
105        this.domain = cookie.getDomain();
106        this.path = cookie.getPath();
107        this.maxAge = Math.max(DEFAULT_MAX_AGE, cookie.getMaxAge());
108        this.version = Math.max(DEFAULT_VERSION, cookie.getVersion());
109        this.secure = cookie.isSecure();
110        this.httpOnly = cookie.isHttpOnly();
111        this.sameSite = cookie.getSameSite();
112    }
113
114    @Override
115    public String getName() {
116        return name;
117    }
118
119    @Override
120    public void setName(String name) {
121        if (!StringUtils.hasText(name)) {
122            throw new IllegalArgumentException("Name cannot be null/empty.");
123        }
124        this.name = name;
125    }
126
127    @Override
128    public String getValue() {
129        return value;
130    }
131
132    @Override
133    public void setValue(String value) {
134        this.value = value;
135    }
136
137    @Override
138    public String getComment() {
139        return comment;
140    }
141
142    @Override
143    public void setComment(String comment) {
144        this.comment = comment;
145    }
146
147    @Override
148    public String getDomain() {
149        return domain;
150    }
151
152    @Override
153    public void setDomain(String domain) {
154        this.domain = domain;
155    }
156
157    @Override
158    public String getPath() {
159        return path;
160    }
161
162    @Override
163    public void setPath(String path) {
164        this.path = path;
165    }
166
167    @Override
168    public int getMaxAge() {
169        return maxAge;
170    }
171
172    @Override
173    public void setMaxAge(int maxAge) {
174        this.maxAge = Math.max(DEFAULT_MAX_AGE, maxAge);
175    }
176
177    @Override
178    public int getVersion() {
179        return version;
180    }
181
182    @Override
183    public void setVersion(int version) {
184        this.version = Math.max(DEFAULT_VERSION, version);
185    }
186
187    @Override
188    public boolean isSecure() {
189        return secure;
190    }
191
192    @Override
193    public void setSecure(boolean secure) {
194        this.secure = secure;
195    }
196
197    @Override
198    public boolean isHttpOnly() {
199        return httpOnly;
200    }
201
202    @Override
203    public void setHttpOnly(boolean httpOnly) {
204        this.httpOnly = httpOnly;
205    }
206
207    @Override
208    public SameSiteOptions getSameSite() {
209        return sameSite;
210    }
211
212    @Override
213    public void setSameSite(SameSiteOptions sameSite) {
214        this.sameSite = sameSite;
215        if (this.sameSite == SameSiteOptions.NONE) {
216            // do not allow invalid cookies. Only secure cookies are allowed if SameSite is set to NONE.
217            setSecure(true);
218        }
219    }
220
221    /**
222     * Returns the Cookie's calculated path setting.  If the {@link javax.servlet.http.Cookie#getPath() path} is {@code null},
223     * then the {@code request}'s {@link javax.servlet.http.HttpServletRequest#getContextPath() context path}
224     * will be returned. If getContextPath() is the empty string or null then the ROOT_PATH constant is returned.
225     *
226     * @param request the incoming HttpServletRequest
227     * @return the path to be used as the path when the cookie is created or removed
228     */
229    private String calculatePath(HttpServletRequest request) {
230        String path = StringUtils.clean(getPath());
231        if (!StringUtils.hasText(path)) {
232            path = StringUtils.clean(request.getContextPath());
233        }
234
235        //fix for http://issues.apache.org/jira/browse/SHIRO-9:
236        if (path == null) {
237            path = ROOT_PATH;
238        }
239        LOGGER.trace("calculated path: {}", path);
240        return path;
241    }
242
243    @Override
244    public void saveTo(HttpServletRequest request, HttpServletResponse response) {
245
246        String name = getName();
247        String value = getValue();
248        String comment = getComment();
249        String domain = getDomain();
250        String path = calculatePath(request);
251        int maxAge = getMaxAge();
252        int version = getVersion();
253        boolean secure = isSecure();
254        boolean httpOnly = isHttpOnly();
255        SameSiteOptions sameSite = getSameSite();
256
257        addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly, sameSite);
258    }
259
260    private void addCookieHeader(HttpServletResponse response, String name, String value, String comment,
261                                 String domain, String path, int maxAge, int version,
262                                 boolean secure, boolean httpOnly, SameSiteOptions sameSite) {
263
264        String headerValue = buildHeaderValue(name, value, comment, domain, path, maxAge, version, secure, httpOnly, sameSite);
265        response.addHeader(COOKIE_HEADER_NAME, headerValue);
266
267        if (LOGGER.isDebugEnabled()) {
268            LOGGER.debug("Added HttpServletResponse Cookie [{}]", headerValue);
269        }
270    }
271
272    /*
273     * This implementation followed the grammar defined here for convenience:
274     * <a href="http://github.com/abarth/http-state/blob/master/notes/2009-11-07-Yui-Naruse.txt">Cookie grammar</a>.
275     *
276     * @return the 'Set-Cookie' header value for this cookie instance.
277     */
278
279    protected String buildHeaderValue(String name, String value, String comment,
280                                      String domain, String path, int maxAge, int version,
281                                      boolean secure, boolean httpOnly) {
282
283        return buildHeaderValue(name, value, comment, domain, path, maxAge, version, secure, httpOnly, getSameSite());
284    }
285
286    protected String buildHeaderValue(String name, String value, String comment,
287                                      String domain, String path, int maxAge, int version,
288                                      boolean secure, boolean httpOnly, SameSiteOptions sameSite) {
289
290        if (!StringUtils.hasText(name)) {
291            throw new IllegalStateException("Cookie name cannot be null/empty.");
292        }
293
294        StringBuilder sb = new StringBuilder(name).append(NAME_VALUE_DELIMITER);
295
296        if (StringUtils.hasText(value)) {
297            sb.append(value);
298        }
299
300        appendComment(sb, comment);
301        appendDomain(sb, domain);
302        appendPath(sb, path);
303        appendExpires(sb, maxAge);
304        appendVersion(sb, version);
305        appendSecure(sb, secure);
306        appendHttpOnly(sb, httpOnly);
307        appendSameSite(sb, sameSite);
308
309        return sb.toString();
310
311    }
312
313    private void appendComment(StringBuilder sb, String comment) {
314        if (StringUtils.hasText(comment)) {
315            sb.append(ATTRIBUTE_DELIMITER);
316            sb.append(COMMENT_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(comment);
317        }
318    }
319
320    private void appendDomain(StringBuilder sb, String domain) {
321        if (StringUtils.hasText(domain)) {
322            sb.append(ATTRIBUTE_DELIMITER);
323            sb.append(DOMAIN_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(domain);
324        }
325    }
326
327    private void appendPath(StringBuilder sb, String path) {
328        if (StringUtils.hasText(path)) {
329            sb.append(ATTRIBUTE_DELIMITER);
330            sb.append(PATH_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(path);
331        }
332    }
333
334    private void appendExpires(StringBuilder sb, int maxAge) {
335        // if maxAge is negative, cookie should should expire when browser closes
336        // Don't write the maxAge cookie value if it's negative - at least on Firefox it'll cause the
337        // cookie to be deleted immediately
338        // Write the expires header used by older browsers, but may be unnecessary
339        // and it is not by the spec, see http://www.faqs.org/rfcs/rfc2965.html
340        // TODO consider completely removing the following
341        if (maxAge >= 0) {
342            sb.append(ATTRIBUTE_DELIMITER);
343            sb.append(MAXAGE_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(maxAge);
344            sb.append(ATTRIBUTE_DELIMITER);
345            Date expires;
346            if (maxAge == 0) {
347                //delete the cookie by specifying a time in the past (1 day ago):
348                expires = new Date(System.currentTimeMillis() - DAY_MILLIS);
349            } else {
350                //Value is in seconds.  So take 'now' and add that many seconds, and that's our expiration date:
351                Calendar cal = Calendar.getInstance();
352                cal.add(Calendar.SECOND, maxAge);
353                expires = cal.getTime();
354            }
355            String formatted = toCookieDate(expires);
356            sb.append(EXPIRES_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(formatted);
357        }
358    }
359
360    private void appendVersion(StringBuilder sb, int version) {
361        if (version > DEFAULT_VERSION) {
362            sb.append(ATTRIBUTE_DELIMITER);
363            sb.append(VERSION_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(version);
364        }
365    }
366
367    private void appendSecure(StringBuilder sb, boolean secure) {
368        if (secure) {
369            sb.append(ATTRIBUTE_DELIMITER);
370            //No value for this attribute
371            sb.append(SECURE_ATTRIBUTE_NAME);
372        }
373    }
374
375    private void appendHttpOnly(StringBuilder sb, boolean httpOnly) {
376        if (httpOnly) {
377            sb.append(ATTRIBUTE_DELIMITER);
378            //No value for this attribute
379            sb.append(HTTP_ONLY_ATTRIBUTE_NAME);
380        }
381    }
382
383    private void appendSameSite(StringBuilder sb, SameSiteOptions sameSite) {
384        if (sameSite != null) {
385            sb.append(ATTRIBUTE_DELIMITER);
386            sb.append(SAME_SITE_ATTRIBUTE_NAME)
387                    .append(NAME_VALUE_DELIMITER)
388                    .append(sameSite.toString().toLowerCase(Locale.ENGLISH));
389        }
390    }
391
392    /**
393     * Check whether the given {@code cookiePath} matches the {@code requestPath}
394     *
395     * @param cookiePath  cookiePath
396     * @param requestPath requestPath
397     * @return boolean
398     * @see <a href="https://tools.ietf.org/html/rfc6265#section-5.1.4">RFC 6265, Section 5.1.4 "Paths and Path-Match"</a>
399     */
400    private boolean pathMatches(String cookiePath, String requestPath) {
401        if (!requestPath.startsWith(cookiePath)) {
402            return false;
403        }
404
405        return requestPath.length() == cookiePath.length()
406                || cookiePath.charAt(cookiePath.length() - 1) == '/'
407                || requestPath.charAt(cookiePath.length()) == '/';
408    }
409
410    /**
411     * Formats a date into a cookie date compatible string (Netscape's specification).
412     *
413     * @param date the date to format
414     * @return an HTTP 1.0/1.1 Cookie compatible date string (GMT-based).
415     */
416    private static String toCookieDate(Date date) {
417        TimeZone tz = TimeZone.getTimeZone(GMT_TIME_ZONE_ID);
418        DateFormat fmt = new SimpleDateFormat(COOKIE_DATE_FORMAT_STRING, Locale.US);
419        fmt.setTimeZone(tz);
420        return fmt.format(date);
421    }
422
423    @Override
424    public void removeFrom(HttpServletRequest request, HttpServletResponse response) {
425        String name = getName();
426        String value = DELETED_COOKIE_VALUE;
427        //don't need to add extra size to the response - comments are irrelevant for deletions
428        String comment = null;
429        String domain = getDomain();
430        String path = calculatePath(request);
431        //always zero for deletion
432        int maxAge = 0;
433        int version = getVersion();
434        boolean secure = isSecure();
435        //no need to add the extra text, plus the value 'deleteMe' is not sensitive at all
436        boolean httpOnly = false;
437        SameSiteOptions sameSite = getSameSite();
438
439        addCookieHeader(response, name, value, null, domain, path, maxAge, version, secure, httpOnly, sameSite);
440
441        LOGGER.trace("Removed '{}' cookie by setting maxAge=0", name);
442    }
443
444    @Override
445    public String readValue(HttpServletRequest request, HttpServletResponse ignored) {
446        String name = getName();
447        String value = null;
448        javax.servlet.http.Cookie cookie = getCookie(request, name);
449        if (cookie != null) {
450            // Validate that the cookie is used at the correct place.
451            String path = StringUtils.clean(getPath());
452            if (path != null && !pathMatches(path, request.getRequestURI())) {
453                LOGGER.warn("Found '{}' cookie at path '{}', but should be only used for '{}'",
454                        name, Encode.forHtml(request.getRequestURI()), path);
455            } else {
456                value = cookie.getValue();
457                LOGGER.debug("Found '{}' cookie value [{}]", name, Encode.forHtml(value));
458            }
459        } else {
460            LOGGER.trace("No '{}' cookie value", name);
461        }
462
463        return value;
464    }
465
466    /**
467     * Returns the cookie with the given name from the request or {@code null} if no cookie
468     * with that name could be found.
469     *
470     * @param request    the current executing http request.
471     * @param cookieName the name of the cookie to find and return.
472     * @return the cookie with the given name from the request or {@code null} if no cookie
473     * with that name could be found.
474     */
475    private static javax.servlet.http.Cookie getCookie(HttpServletRequest request, String cookieName) {
476        javax.servlet.http.Cookie[] cookies = request.getCookies();
477        if (cookies != null) {
478            for (javax.servlet.http.Cookie cookie : cookies) {
479                if (cookie.getName().equals(cookieName)) {
480                    return cookie;
481                }
482            }
483        }
484        return null;
485    }
486}