diff --git a/guacamole-ext/pom.xml b/guacamole-ext/pom.xml
index e175b300d..ba0b84813 100644
--- a/guacamole-ext/pom.xml
+++ b/guacamole-ext/pom.xml
@@ -112,6 +112,14 @@
compile
+
+
+ junit
+ junit
+ 4.10
+ test
+
+
diff --git a/guacamole-ext/src/main/java/org/glyptodon/guacamole/token/TokenFilter.java b/guacamole-ext/src/main/java/org/glyptodon/guacamole/token/TokenFilter.java
new file mode 100644
index 000000000..527942bfc
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/glyptodon/guacamole/token/TokenFilter.java
@@ -0,0 +1,213 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.token;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Filtering object which replaces tokens of the form "${TOKEN_NAME}" with
+ * their corresponding values. Unknown tokens are not replaced. If TOKEN_NAME
+ * is a valid token, the literal value "${TOKEN_NAME}" can be included by using
+ * "$${TOKEN_NAME}".
+ *
+ * @author Michael Jumper
+ */
+public class TokenFilter {
+
+ /**
+ * Regular expression which matches individual tokens, with additional
+ * capturing groups for convenient retrieval of leading text, the possible
+ * escape character preceding the token, the name of the token, and the
+ * entire token itself.
+ */
+ private final Pattern tokenPattern = Pattern.compile("(.*?)(^|.)(\\$\\{([A-Za-z0-9_]*)\\})");
+
+ /**
+ * The index of the capturing group within tokenPattern which matches
+ * non-token text preceding a possible token.
+ */
+ private static final int LEADING_TEXT_GROUP = 1;
+
+ /**
+ * The index of the capturing group within tokenPattern which matches the
+ * character immediately preceding a possible token, possibly denoting that
+ * the token should instead be interpreted as a literal.
+ */
+ private static final int ESCAPE_CHAR_GROUP = 2;
+
+ /**
+ * The index of the capturing group within tokenPattern which matches the
+ * entire token, including the leading "${" and terminating "}" strings.
+ */
+ private static final int TOKEN_GROUP = 3;
+
+ /**
+ * The index of the capturing group within tokenPattern which matches only
+ * the token name contained within the "${" and "}" strings.
+ */
+ private static final int TOKEN_NAME_GROUP = 4;
+
+ /**
+ * The values of all known tokens.
+ */
+ private final Map tokenValues = new HashMap();
+
+ /**
+ * Sets the token having the given name to the given value. Any existing
+ * value for that token is replaced.
+ *
+ * @param name
+ * The name of the token to set.
+ *
+ * @param value
+ * The value to set the token to.
+ */
+ public void setToken(String name, String value) {
+ tokenValues.put(name, value);
+ }
+
+ /**
+ * Returns the value of the token with the given name, or null if no such
+ * token has been set.
+ *
+ * @param name
+ * The name of the token to return.
+ *
+ * @return
+ * The value of the token with the given name, or null if no such
+ * token exists.
+ */
+ public String getToken(String name) {
+ return tokenValues.get(name);
+ }
+
+ /**
+ * Removes the value of the token with the given name. If no such token
+ * exists, this function has no effect.
+ *
+ * @param name
+ * The name of the token whose value should be removed.
+ */
+ public void unsetToken(String name) {
+ tokenValues.remove(name);
+ }
+
+ /**
+ * Returns a map of all tokens, with each key being a token name, and each
+ * value being the corresponding token value. Changes to this map will
+ * directly affect the tokens associated with this filter.
+ *
+ * @return
+ * A map of all token names and their corresponding values.
+ */
+ public Map getTokens() {
+ return tokenValues;
+ }
+
+ /**
+ * Replaces all current token values with the contents of the given map,
+ * where each map key represents a token name, and each map value
+ * represents a token value.
+ *
+ * @param tokens
+ * A map containing the token names and corresponding values to
+ * assign.
+ */
+ public void setTokens(Map tokens) {
+ tokenValues.clear();
+ tokenValues.putAll(tokens);
+ }
+
+ /**
+ * Filters the given string, replacing any tokens with their corresponding
+ * values.
+ *
+ * @param input
+ * The string to filter.
+ *
+ * @return
+ * A copy of the input string, with any tokens replaced with their
+ * corresponding values.
+ */
+ public String filter(String input) {
+
+ StringBuilder output = new StringBuilder();
+ Matcher tokenMatcher = tokenPattern.matcher(input);
+
+ // Track last regex match
+ int endOfLastMatch = 0;
+
+ // For each possible token
+ while (tokenMatcher.find()) {
+
+ // Pull possible leading text and first char before possible token
+ String literal = tokenMatcher.group(LEADING_TEXT_GROUP);
+ String escape = tokenMatcher.group(ESCAPE_CHAR_GROUP);
+
+ // Append leading non-token text
+ output.append(literal);
+
+ // If char before token is '$', the token itself is escaped
+ if ("$".equals(escape)) {
+ String notToken = tokenMatcher.group(TOKEN_GROUP);
+ output.append(notToken);
+ }
+
+ // If char is not '$', interpret as a token
+ else {
+
+ // The char before the token, if any, is a literal
+ output.append(escape);
+
+ // Pull token value
+ String tokenName = tokenMatcher.group(TOKEN_NAME_GROUP);
+ String tokenValue = getToken(tokenName);
+
+ // If token is unknown, interpret as literal
+ if (tokenValue == null) {
+ String notToken = tokenMatcher.group(TOKEN_GROUP);
+ output.append(notToken);
+ }
+
+ // Otherwise, substitute value
+ else
+ output.append(tokenValue);
+
+ }
+
+ // Update last regex match
+ endOfLastMatch = tokenMatcher.end();
+
+ }
+
+ // Append any remaining non-token text
+ output.append(input.substring(endOfLastMatch));
+
+ return output.toString();
+
+ }
+
+}
diff --git a/guacamole-ext/src/test/java/org/glyptodon/guacamole/token/TokenFilterTest.java b/guacamole-ext/src/test/java/org/glyptodon/guacamole/token/TokenFilterTest.java
new file mode 100644
index 000000000..d1c433b00
--- /dev/null
+++ b/guacamole-ext/src/test/java/org/glyptodon/guacamole/token/TokenFilterTest.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2015 Glyptodon LLC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.glyptodon.guacamole.token;
+
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+/**
+ * Test which verifies the filtering functionality of TokenFilter.
+ *
+ * @author Michael Jumper
+ */
+public class TokenFilterTest {
+
+ /**
+ * Verifies that token replacement via filter() functions as specified.
+ */
+ @Test
+ public void testFilter() {
+
+ // Create token filter
+ TokenFilter tokenFilter = new TokenFilter();
+ tokenFilter.setToken("TOKEN_A", "value-of-a");
+ tokenFilter.setToken("TOKEN_B", "value-of-b");
+
+ // Test basic substitution and escaping
+ assertEquals(
+ "$${NOPE}hellovalue-of-aworldvalue-of-b${NOT_A_TOKEN}",
+ tokenFilter.filter("$$${NOPE}hello${TOKEN_A}world${TOKEN_B}$${NOT_A_TOKEN}")
+ );
+
+ // Unknown tokens must be interpreted as literals
+ assertEquals(
+ "${NOPE}hellovalue-of-aworld${TOKEN_C}",
+ tokenFilter.filter("${NOPE}hello${TOKEN_A}world${TOKEN_C}")
+ );
+
+ }
+
+}