001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions Copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2009 Sun Microsystems Inc.
015 * Portions Copyright 2010–2011 ApexIdentity Inc.
016 * Portions Copyright 2011-2014 ForgeRock AS.
017 */
018
019package org.forgerock.openig.filter;
020
021import java.io.IOException;
022import java.net.CookieManager;
023import java.net.CookiePolicy;
024import java.net.HttpCookie;
025import java.net.URI;
026import java.net.URISyntaxException;
027import java.util.ArrayList;
028import java.util.Arrays;
029import java.util.Collections;
030import java.util.List;
031import java.util.ListIterator;
032import java.util.regex.Pattern;
033
034import org.forgerock.openig.handler.Handler;
035import org.forgerock.openig.handler.HandlerException;
036import org.forgerock.openig.heap.GenericHeaplet;
037import org.forgerock.openig.heap.HeapException;
038import org.forgerock.openig.http.Exchange;
039import org.forgerock.openig.http.Request;
040import org.forgerock.openig.http.Response;
041import org.forgerock.openig.http.Session;
042import org.forgerock.openig.util.CaseInsensitiveSet;
043import org.forgerock.openig.util.MutableUri;
044import org.forgerock.openig.util.StringUtil;
045
046/**
047 * Suppresses, relays and manages cookies. The names of filtered cookies are stored in one of
048 * three action set variables: {@code suppressed}, {@code relayed} and {@code managed}. If a
049 * cookie is not found in any of the action sets, then a default action is selected.
050 * <p>
051 * The default action is controlled by setting the {@code defaultAction} field. The default
052 * action at initialization is to manage all cookies. In the event a cookie appears in more
053 * than one action set, then it will be selected in order of precedence: managed, suppressed,
054 * relayed.
055 * <p>
056 * Managed cookies are intercepted by the cookie filter itself and stored in the request
057 * {@link Session} object. The default {@code policy} is to accept all incoming cookies, but
058 * can be changed to others as appropriate.
059 */
060public class CookieFilter extends GenericFilter {
061
062    /** Action to be performed for a cookie. */
063    public enum Action {
064        /** Intercept and manage the cookie within the proxy. */
065        MANAGE,
066        /** Remove the cookie from request and response. */
067        SUPPRESS,
068        /** Relay the cookie between remote client and remote host. */
069        RELAY
070    }
071
072// TODO: Use the org.forgerock.openig.header framework now for parsing, not regexes anymore.
073
074    /** Splits string using comma delimiter, outside of quotes. */
075    private static final Pattern DELIM_COMMA = Pattern.compile(",(?=([^\"]*\"[^\"]*\")*(?![^\"]*\"))");
076
077    /** Splits string using equals sign delimiter, outside of quotes. */
078    private static final Pattern DELIM_EQUALS = Pattern.compile("=(?=([^\"]*\"[^\"]*\")*(?![^\"]*\"))");
079
080    /** Splits string using semicolon delimiter, outside of quotes. */
081    private static final Pattern DELIM_SEMICOLON = Pattern.compile(";(?=([^\"]*\"[^\"]*\")*(?![^\"]*\"))");
082
083    /** Splits string using colon delimiter. */
084    private static final Pattern DELIM_COLON = Pattern.compile(":");
085
086    /** Response headers to parse. */
087    private static final String[] RESPONSE_HEADERS = {"Set-Cookie", "Set-Cookie2"};
088
089    /** Action to perform for cookies that do not match an action set. Default: manage. */
090    private Action defaultAction = Action.MANAGE;
091
092    /** The policy for managed cookies. Default: accept all cookies. */
093    private CookiePolicy policy = CookiePolicy.ACCEPT_ALL;
094
095    /** Action set for cookies to be suppressed. */
096    private final CaseInsensitiveSet suppressed = new CaseInsensitiveSet();
097
098    /** Action set for cookies to be relayed. */
099    private final CaseInsensitiveSet relayed = new CaseInsensitiveSet();
100
101    /** Action set for cookies that filter should intercept and manage. */
102    private final CaseInsensitiveSet managed = new CaseInsensitiveSet();
103
104    /**
105     * Set the action to perform for cookies that do not match an action set. Default: {@link Action#MANAGE}.
106     * @param defaultAction the action to perform for cookies that do not match an action set.
107     */
108    public void setDefaultAction(final Action defaultAction) {
109        this.defaultAction = defaultAction;
110    }
111
112    /**
113     * Set the policy for managed cookies. Default: accept all cookies ({@link CookiePolicy#ACCEPT_ALL}).
114     * @param policy the policy for managed cookies.
115     */
116    public void setPolicy(final CookiePolicy policy) {
117        this.policy = policy;
118    }
119
120    /**
121     * Returns the set of cookie names that will be suppressed from the request and from the response.
122     * @return the set of suppressed cookie identifiers.
123     */
124    public CaseInsensitiveSet getSuppressed() {
125        return suppressed;
126    }
127
128    /**
129     * Returns the set of cookie names that will be relayed ({@literal Cookie} transmitted from the
130     * client to the next handler in the context of a request, and {@literal Set-Cookie2} transmitted
131     * from the next handler to the client in the context of a response).
132     * @return the set of relayed cookie identifiers.
133     */
134    public CaseInsensitiveSet getRelayed() {
135        return relayed;
136    }
137
138    /**
139     * Returns the set of cookie names that will be managed.
140     * @return the set of managed cookie identifiers.
141     */
142    public CaseInsensitiveSet getManaged() {
143        return managed;
144    }
145
146    /**
147     * Resolves the request URI based on the request URI variable and optional
148     * Host header. This allows the request URI to contain a raw IP address,
149     * while the Host header resolves the hostname and port that the remote
150     * client used to access it.
151     * <p>
152     * Note: This method returns a normalized URI, as though returned by the
153     * {@link URI#normalize} method.
154     *
155     * @return the resolved URI value.
156     */
157// TODO: Rewrite and put in URIutil.
158    private MutableUri resolveHostURI(Request request) {
159        MutableUri uri = request.getUri();
160        String header = (request.getHeaders() != null ? request.getHeaders().getFirst("Host") : null);
161        if (uri != null && header != null) {
162            String[] hostport = DELIM_COLON.split(header, 2);
163            int port;
164            try {
165                port = (hostport.length == 2 ? Integer.parseInt(hostport[1]) : -1);
166            } catch (NumberFormatException nfe) {
167                port = -1;
168            }
169            try {
170                uri = new MutableUri(uri.getScheme(),
171                                     null,
172                                     hostport[0],
173                                     port,
174                                     "/",
175                                     null,
176                                     null).resolve(new MutableUri(uri.getScheme(),
177                                                                  null,
178                                                                  uri.getHost(),
179                                                                  uri.getPort(),
180                                                                  null,
181                                                                  null,
182                                                                  null).relativize(uri));
183            } catch (URISyntaxException use) {
184                // suppress exception
185            }
186        }
187        return uri;
188    }
189
190    /**
191     * Sets all request cookies (existing in request plus those to add from cookie jar) in
192     * a single "Cookie" header in the request.
193     */
194    private void addRequestCookies(CookieManager manager, MutableUri resolved, Request request) throws IOException {
195        List<String> cookies = request.getHeaders().get("Cookie");
196        if (cookies == null) {
197            cookies = new ArrayList<String>();
198        }
199        List<String> managed = manager.get(resolved.asURI(), request.getHeaders()).get("Cookie");
200        if (managed != null) {
201            cookies.addAll(managed);
202        }
203        StringBuilder sb = new StringBuilder();
204        for (String cookie : cookies) {
205            if (sb.length() > 0) {
206                sb.append("; ");
207            }
208            sb.append(cookie);
209        }
210        if (sb.length() > 0) {
211            // replace any existing header(s)
212            request.getHeaders().putSingle("Cookie", sb.toString());
213        }
214    }
215
216    @Override
217    public void filter(Exchange exchange, Handler next) throws HandlerException, IOException {
218        // resolve to client-supplied host header
219        MutableUri resolved = resolveHostURI(exchange.request);
220        // session cookie jar
221        CookieManager manager = getManager(exchange.session);
222        // remove cookies that are suppressed or managed
223        suppress(exchange.request);
224        // add any request cookies to header
225        addRequestCookies(manager, resolved, exchange.request);
226        // pass exchange to next handler in chain
227        next.handle(exchange);
228        // manage cookie headers in response
229        manager.put(resolved.asURI(), exchange.response.getHeaders());
230        // remove cookies that are suppressed or managed
231        suppress(exchange.response);
232    }
233
234    /**
235     * Computes what action to perform for the specified cookie name.
236     *
237     * @param name the name of the cookie to compute action for.
238     * @return the computed action to perform for the given cookie.
239     */
240    private Action action(String name) {
241        if (managed.contains(name)) {
242            return Action.MANAGE;
243        } else if (suppressed.contains(name)) {
244            return Action.SUPPRESS;
245        } else if (relayed.contains(name)) {
246            return Action.RELAY;
247        } else {
248            return defaultAction;
249        }
250    }
251
252    /**
253     * Returns the cookie manager for the session, creating one if it does not already exist.
254     *
255     * @param session the session that contains the cookie manager.
256     * @return the retrieved (or created) cookie manager.
257     */
258    private CookieManager getManager(Session session) {
259        CookieManager manager = null;
260        // prevent a race for the cookie manager
261        synchronized (session) {
262            manager = (CookieManager) session.get(CookieManager.class.getName());
263            if (manager == null) {
264                manager = new CookieManager(null, new CookiePolicy() {
265                    public boolean shouldAccept(URI uri, HttpCookie cookie) {
266                        return (action(cookie.getName()) == Action.MANAGE && policy.shouldAccept(uri, cookie));
267                    }
268                });
269                session.put(CookieManager.class.getName(), manager);
270            }
271        }
272        return manager;
273    }
274
275    /**
276     * Removes the cookies from the request that are suppressed or managed.
277     *
278     * @param request the request to suppress the cookies in.
279     */
280    private void suppress(Request request) {
281        List<String> headers = request.getHeaders().get("Cookie");
282        if (headers != null) {
283            for (ListIterator<String> hi = headers.listIterator(); hi.hasNext();) {
284                String header = hi.next();
285                ArrayList<String> parts = new ArrayList<String>(Arrays.asList(DELIM_SEMICOLON.split(header, 0)));
286                int originalSize = parts.size();
287                boolean remove = false;
288                int intact = 0;
289                for (ListIterator<String> pi = parts.listIterator(); pi.hasNext();) {
290                    String part = pi.next().trim();
291                    if (part.length() != 0 && part.charAt(0) == '$') {
292                        if (remove) {
293                            pi.remove();
294                        }
295                    } else {
296                        Action action = action((DELIM_EQUALS.split(part, 2))[0].trim());
297                        if (action == Action.SUPPRESS || action == Action.MANAGE) {
298                            pi.remove();
299                            remove = true;
300                        } else {
301                            intact++;
302                            remove = false;
303                        }
304                    }
305                }
306                if (intact == 0) {
307                    hi.remove();
308                } else if (parts.size() != originalSize) {
309                    hi.set(StringUtil.join(";", parts));
310                }
311            }
312            if (headers.isEmpty()) {
313                request.getHeaders().remove("Cookie");
314            }
315        }
316    }
317
318    /**
319     * Removes the cookies from the response that are suppressed or managed.
320     *
321     * @param response the response to suppress the cookies in.
322     */
323    private void suppress(Response response) {
324        for (String name : RESPONSE_HEADERS) {
325            List<String> headers = response.getHeaders().get(name);
326            if (headers != null) {
327                for (ListIterator<String> hi = headers.listIterator(); hi.hasNext();) {
328                    String header = hi.next();
329                    ArrayList<String> parts;
330                    if (name.equals("Set-Cookie2")) {
331                        // RFC 2965 cookie
332                        parts = new ArrayList<String>(Arrays.asList(DELIM_COMMA.split(header, 0)));
333                    } else {
334                        // Netscape cookie
335                        parts = new ArrayList<String>();
336                        parts.add(header);
337                    }
338                    int originalSize = parts.size();
339                    for (ListIterator<String> pi = parts.listIterator(); pi.hasNext();) {
340                        String part = pi.next();
341                        Action action = action((DELIM_EQUALS.split(part, 2))[0].trim());
342                        if (action == Action.SUPPRESS || action == Action.MANAGE) {
343                            pi.remove();
344                        }
345                    }
346                    if (parts.size() == 0) {
347                        hi.remove();
348                    } else if (parts.size() != originalSize) {
349                        hi.set(StringUtil.join(",", parts));
350                    }
351                }
352            }
353        }
354    }
355
356    /**
357     * Creates and initializes a cookie filter in a heap environment.
358     */
359    public static class Heaplet extends GenericHeaplet {
360        @Override
361        public Object create() throws HeapException {
362            CookieFilter filter = new CookieFilter();
363            filter.suppressed.addAll(config.get("suppressed")
364                    .defaultTo(Collections.emptyList())
365                    .asList(String.class));
366            filter.relayed.addAll(config.get("relayed")
367                    .defaultTo(Collections.emptyList())
368                    .asList(String.class));
369            filter.managed.addAll(config.get("managed")
370                    .defaultTo(Collections.emptyList())
371                    .asList(String.class));
372            filter.defaultAction = config.get("defaultAction")
373                    .defaultTo(filter.defaultAction.toString())
374                    .asEnum(Action.class);
375            return filter;
376        }
377    }
378}