Servlet filter plugins

Plugin servlet filter hook classes operate on a search request before it begins Funnelback processing, and on the search response, before it is processed by Funnelback and also after result rendering, just before a response is returned to the user.

Search lifecycle plugins are suitable for must use cases and are much easier to work with, however in some advanced cases servlet filters are required.

One specific capability enabled by this type of hook is auditing or direct manipulation of the specific byte output returned to Funnelback users.

When developing custom servlet filter hooks it will be useful to be familiar with Java servlet filter mechanism, on which this mechanism is based.

Plugin scopes

The plugin scope for a plugin that implements servlet filtering must include the runs on results page scope.

Interface methods

To implement a custom servlet filter hook, you will need to implement the SearchServletFilterHook interface.

SearchServletFilterHook provides three different methods:

ServletRequest preFilterRequest(SearchServletFilterHookContext context, ServletRequest request)

Allows the SearchServletFilterHook implementation to perform actions on the request before it is populated by Funnelback.

ServletResponse preFilterResponse(SearchServletFilterHookContext context, ServletRequest request, ServletResponse response)

Allows the SearchServletFilterHook implementation to perform actions on the response before it is populated by Funnelback.

void postFilterResponse(SearchServletFilterHookContext context, ServletRequest request, ServletResponse response)

Allows the SearchServletFilterHook implementation to perform actions after Funnelback has processed the request.

The methods provided by this interface, preFilterRequest, preFilterResponse and postFilterResponse, allow the hook class to intercept and alter the ServletRequest and ServletResponse objects as necessary. There is also access to the profile configuration for the request, via SearchServletFilterHookContext#getCurrentProfileConfig()

Access to the Funnelback search transaction object model is not available from within this hook class, and that all requests to Funnelback’s search interface will be processed through these methods, so the performance of their implementations must be considered during development.

Example: x-forwarded-for-edit

Manipulates the X-Forwarded-For header of HTTP Search Requests.

HttpServletRequestXForwardedForWrapper.java
package com.funnelback.xforwardedforedit;


import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

/**
 * Class to manipulate the X-Forwarded-For header of an HTTP request
 *
 * Allows removal of the first or last value of the X-Forwarded-For
 * or removal of all values but the first
 */
class HttpServletRequestXForwardedForWrapper extends HttpServletRequestWrapper {

    private static final Logger log = LogManager.getLogger(HttpServletRequestXForwardedForWrapper.class);

    static final String XFF_HEADER = "X-Forwarded-For";

    public static Optional<Mode> getMode(String modeString) {
        for (Mode mode : Mode.values()) {
            if (mode.name().equalsIgnoreCase(modeString)) {
                return Optional.of(mode);
            }
        }
        return Optional.empty();
    }

    /**
     * Possible modes of operation
     */
    public enum Mode {
        /** Remove first value of the X-Forwarded-For */
        RemoveFirst,
        /** Remove last value of the X-Forwarded-For */
        RemoveLast,
        /** Keep only the first value of the X-Forwarded-For */
        KeepFirst,
    }


    /** Mode of operation */
    final Mode mode;

    HttpServletRequestXForwardedForWrapper(Mode mode, HttpServletRequest request) {
        super(request);
        this.mode = mode;
    }

    /**
     * Check if the requested header is X-Forwarded-For, and if it is
     * alter the value according to the mode
     *
     * @param name Name of the header to retrieve
     * @return Header value, possibly altered
     */
    @Override
    public String getHeader(String name) {

        if (name.toLowerCase().equals(XFF_HEADER.toLowerCase())) {
            String oldHeader = super.getHeader(name);
            String updatedHeader = "";

            if (oldHeader != null && oldHeader.contains(",")) {
                List<String> xffEntries = List.of(oldHeader.split(","));
                try {
                    switch (mode) {
                        case RemoveFirst:
                            updatedHeader = xffEntries.subList(1, xffEntries.size())
                                    .stream().collect(Collectors.joining(","));
                            break;
                        case RemoveLast:
                            updatedHeader = xffEntries.subList(0, xffEntries.size() - 1)
                                    .stream().collect(Collectors.joining(","));
                            break;
                        case KeepFirst:
                            updatedHeader = xffEntries.get(0);
                            break;
                    }
                    log.debug("Changed '{}' value from '{}' to '{}'", XFF_HEADER, oldHeader, updatedHeader);
                    return updatedHeader;
                } catch (Exception e) {
                    log.error("Error while altering ${XFF_HEADER} value '{}'", oldHeader, e);
                }
            }
        }

        return super.getHeader(name);
    }
}
XForwardedForEditSearchServletFilterPluginTest.java
package com.funnelback.xforwardedforedit;

import com.funnelback.plugin.servlet.filter.SearchServletFilterHookContext;
import com.funnelback.publicui.search.model.collection.ServiceConfig;
import org.junit.Test;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Set;

import static com.funnelback.xforwardedforedit.HttpServletRequestXForwardedForWrapper.XFF_HEADER;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.matches;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class XForwardedForEditSearchServletFilterPluginTest {

    @Test
    public void testKeepFirstHeader() {
        assertEquals("1.1.1.1", makeTestRequest("1.1.1.1,2.2.2.2", "KeepFirst").getHeader(XFF_HEADER));
        assertEquals("1.1.1.1", makeTestRequest("1.1.1.1", "KeepFirst").getHeader(XFF_HEADER));
        assertEquals("", makeTestRequest("", "KeepFirst").getHeader(XFF_HEADER));
        assertEquals(null, makeTestRequest(null, "KeepFirst").getHeader(XFF_HEADER));
    }

    @Test
    public void testRemoveFirstHeader() {
        assertEquals("2.2.2.2,3.3.3.3", makeTestRequest("1.1.1.1,2.2.2.2,3.3.3.3", "RemoveFirst").getHeader(XFF_HEADER));
        assertEquals("2.2.2.2", makeTestRequest("1.1.1.1,2.2.2.2", "RemoveFirst").getHeader(XFF_HEADER));
        assertEquals(
                "Does not remove x-forwarded-for header if there is only one value",
                "1.1.1.1", makeTestRequest("1.1.1.1", "RemoveFirst").getHeader(XFF_HEADER));
        assertEquals("", makeTestRequest("", "RemoveFirst").getHeader(XFF_HEADER));
        assertEquals(null, makeTestRequest(null, "RemoveFirst").getHeader(XFF_HEADER));
    }

    @Test
    public void testRemoveLastHeader() {
        assertEquals("1.1.1.1,2.2.2.2", makeTestRequest("1.1.1.1,2.2.2.2,3.3.3.3", "RemoveLast").getHeader(XFF_HEADER));
        assertEquals("1.1.1.1", makeTestRequest("1.1.1.1,2.2.2.2", "RemoveLast").getHeader(XFF_HEADER));
        assertEquals(
                "Does not remove x-forwarded-for header if there is only one value",
                "1.1.1.1", makeTestRequest("1.1.1.1", "RemoveLast").getHeader(XFF_HEADER));
        assertEquals("", makeTestRequest("", "RemoveLast").getHeader(XFF_HEADER));
        assertEquals(null, makeTestRequest(null, "RemoveLast").getHeader(XFF_HEADER));
    }

    @Test
    public void testCaseInsensitveOnHeader() {
        assertEquals(
                "1.1.1.1,2.2.2.2",
                makeTestRequest("1.1.1.1,2.2.2.2,3.3.3.3", "RemoveLast").getHeader(XFF_HEADER.toLowerCase()));
    }

    @Test
    public void testCaseInsensitveOnSetting() {
        assertEquals(
                "1.1.1.1,2.2.2.2",
                makeTestRequest("1.1.1.1,2.2.2.2,3.3.3.3", "RemoveLast".toUpperCase()) .getHeader(XFF_HEADER));
    }

    @Test
    public void testNoMode_NoEffect() {
        checkNoEffectfor("RemoveFirstHeader");
        checkNoEffectfor(null);
        checkNoEffectfor("not a setting");
    }

    public void checkNoEffectfor(String mode) {
        assertEquals("1.1.1.1,2.2.2.2,3.3.3.3",
                makeTestRequest("1.1.1.1,2.2.2.2,3.3.3.3", mode).getHeader(XFF_HEADER));
    }

    HttpServletRequest makeTestRequest(String headerKey, String headerValue, String mode) {
        HttpServletRequest mockServerHttpRequest = mock(HttpServletRequest.class);
        when(mockServerHttpRequest.getHeader(matches("(?i)" + headerKey))).thenReturn(headerValue);
        // Update this to call the method(s) that should be tested.
        return (HttpServletRequest) new XForwardedForEditSearchServletFilterPlugin()
                        .preFilterRequest(
                                new SearchServletFilterHookContext() {
                                    @Override
                                    public ServiceConfig getCurrentProfileConfig() {
                                        return new InMemoryConfig(
                                                mode == null ? null
                                                        : Map.of(PluginUtils.KEY_PREFIX + "mode", mode));
                                    }
                                },
                                mockServerHttpRequest);
    }

    HttpServletRequest makeTestRequest(String xForwardedForHeader, String mode) {
        return makeTestRequest(XFF_HEADER, xForwardedForHeader, mode);
    }


    private static final class InMemoryConfig implements ServiceConfig {

        Map<String, String> config;

        InMemoryConfig(Map<String, String> config) {
            this.config = config;
        }

        @Override
        public String get(String key) {
            return config == null ? null : config.get(key);
        }

        @Override
        public Set<String> getRawKeys() {
            return config == null ? null : config.keySet();
        }
    }

}