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