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