Skip to content

Instantly share code, notes, and snippets.

@subchen
Last active August 29, 2015 14:16
Show Gist options
  • Save subchen/27cf1711812e4d041077 to your computer and use it in GitHub Desktop.
Save subchen/27cf1711812e4d041077 to your computer and use it in GitHub Desktop.
// package de.spieleck.servlets;
// ProxyServlet - serving pages from foreign servers....
//
import java.io.*;
import java.net.*;
import java.lang.Integer;
import java.util.StringTokenizer;
import javax.servlet.*;
import javax.servlet.http.*;
/**
* Serves pages which are fetched from another HTTP-Server
* useful for going thru firewalls and other trickery...
* <P>
* The communication is somewhat this way:
* <UL>
* <LI>Client requests data from servlet
* <LI>Servlet interprets path and requests data from remote server
* <LI>Servlet obtains answer from remote server and forwards it to client
* <LI>Client obtains answer
* </UL>
* <P>
* XXX There is a problem with If-Modified and If-None-Match requests:
* the 304 Not Modified answer does not go thru the servelet in the
* backward direction. It could be that the HttpServletResponse does hava
* some sideeffects which are not helpfull in this special situation.
* This type of request is currently avoided by removing all "If-" requests.
* <br />
* <b>Note:</b> This servlet is actually buggy. It is buggy since it does
* not solve all problems, it only solves the problems I needed to solve.
* Many thanks to Thorsten Gast the creator of dirjack
* for pointing at least some bugs.
* @author <a href="mailto:frank -at- spieleck.de">Frank Nestel</a>.
*/
public class ProxyServlet extends HttpServlet
{
/**
* "Official" HTTP line end
*/
public final static String CRLF = "\r\n";
public final static String LF = "\n";
/**
* remote path
*/
protected String remotePath;
/**
* remote server
*/
protected String remoteServer;
/**
* Port at remote server
*/
protected int remotePort;
/**
* Debug mode?
*/
protected boolean debugFlag;
/** Init
*/
public void init(ServletConfig config)
throws ServletException
{
super.init(config);
remotePath = getInitParameter("remotePath");
remoteServer = getInitParameter("remoteServer");
String remotePortStr= getInitParameter("remotePort");
if ( remotePath == null || remoteServer == null )
throw new ServletException(
"Servlet needs remotePath & remoteServer.");
if ( remotePortStr != null )
{
try
{
remotePort = Integer.parseInt(remotePortStr);
}
catch ( Exception e )
{
throw new ServletException("Port must be a number!");
}
}
else
remotePort = 80;
if ( "".equals(remotePath) )
remotePath = ""; // XXX ??? "/"
else if ( remotePath.charAt(0) != '/' )
remotePath = "/"+remotePath;
debugFlag = "true".equals(getInitParameter("debug"));
//
log("remote="+remoteServer+" "+remotePort+" "+remotePath);
}
/// Returns a string containing information about the author, version, and
// copyright of the servlet.
public String getServletInfo()
{
return "Online redirecting content.";
}
/// Services a single request from the client.
// @param req the servlet request
// @param req the servlet response
// @exception ServletException when an exception has occurred
public void service( HttpServletRequest req, HttpServletResponse res )
throws ServletException, IOException
{
//
// Connect to "remote" server:
Socket sock;
OutputStream out;
InputStream in;
//
try
{
sock = new Socket(remoteServer, remotePort); // !!!!!!!!
out = new BufferedOutputStream(sock.getOutputStream());
in = new BufferedInputStream(sock.getInputStream());
}
catch (IOException e)
{
res.sendError( HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"Socket opening: "+remoteServer+" "+remotePort);
return;
}
try
{
//
// Build up a HTTP request from pure strings:
StringBuffer sb = new StringBuffer(200);
sb.append(req.getMethod());
sb.append(' ');
String pi = req.getPathInfo();
sb.append(remotePath);
if ( pi != null )
{
appendCleaned(sb, pi);
}
else
sb.append("/");
if ( req.getQueryString() != null )
{
sb.append('?');
appendCleaned(sb, req.getQueryString());
}
sb.append(' ');
sb.append("HTTP/1.0");
sb.append(CRLF);
log(sb.toString());
out.write(sb.toString().getBytes());
java.util.Enumeration en = req.getHeaderNames();
while ( en.hasMoreElements() )
{
String k = (String) en.nextElement();
// Filter incoming headers:
if ( "Host".equalsIgnoreCase(k) )
{
sb.setLength(0);
sb.append(k);
sb.append(": ");
sb.append(remoteServer);
sb.append(":");
sb.append(remotePort);
sb.append(CRLF);
log("c["+k+"]: "+sb+" "+req.getHeader(k));
out.write(sb.toString().getBytes());
}
//
// Throw away persistant connections between servers
// Throw away request potentially causing a 304 response.
else if (
! "Connection".equalsIgnoreCase(k)
&& ! "If-Modified-Since".equalsIgnoreCase(k)
&& ! "If-None-Match".equalsIgnoreCase(k)
)
{
sb.setLength(0);
sb.append(k);
sb.append(": ");
sb.append(req.getHeader(k));
sb.append(CRLF);
log("=["+k+"]: "+req.getHeader(k));
out.write(sb.toString().getBytes());
}
else
{
log("*["+k+"]: "+req.getHeader(k));
}
}
// Finish request header by an empty line
out.write(CRLF.getBytes());
// Copy post data
InputStream inr = req.getInputStream();
copyStream(inr, out);
out.flush();
log("Remote request finished. Reading answer.");
// Now we have finished the outgoing request.
// We'll now see, what is coming back from remote:
// Get the answer, treat its header and copy the stream data:
if ( treatHeader(in, req, res) )
{
log("+ copyStream");
// if ( debugFlag ) res.setContentType("text/plain");
out = res.getOutputStream();
copyStream(in, out);
}
else
log("- copyStream");
}
catch (IOException e)
{
log("out-in.open!");
// res.sendError( HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
// "out-in open!");
return;
}
try
{
// out.close();
in.close();
sock.close();
}
catch (IOException ignore)
{
log("Exception "+ignore);
}
}
public static void appendCleaned(StringBuffer sb, String str)
{
for(int i = 0; i < str.length(); i++)
{
char ch = str.charAt(i);
if ( ch == ' ' )
sb.append("%20");
else
sb.append(ch);
}
}
/**
* Forward and filter header from backend Request.
*/
private boolean treatHeader(InputStream in,
HttpServletRequest req,
HttpServletResponse res)
throws ServletException
{
boolean retval = true;
byte[] lineBytes = new byte[4096];
int len;
String line;
try
{
// Read the first line of the request.
len = readLine(in, lineBytes );
if ( len == -1 || len == 0 )
throw new ServletException( "No Request found in Data." );
{
String line2 = new String( lineBytes, 0, len );
log("head: "+line2+" "+len);
}
// We mainly skip the header by the foreign server
// assuming, that we can handle protocoll mismatch or so!
res.setHeader("viaJTTP","JTTP");
// Some more headers require special care ....
boolean firstline = true;
// Shortcut evaluation skips the read on first time!
while ( firstline || ((len=readLine(in,lineBytes)) > 0) )
{
line = new String( lineBytes, 0, len );
int colonPos = line.indexOf( ":" );
if ( firstline && colonPos == -1 )
{
// Special first line considerations ...
String headl[] = wordStr(line);
log("head: "+line+" "+headl.length);
try
{
res.setStatus(Integer.parseInt(headl[1]));
}
catch ( NumberFormatException ignore )
{
log("ID exception: "+headl);
}
catch ( Exception panik )
{
log("First line invalid!");
return true;
}
}
else if ( colonPos != -1 )
{
String head = line.substring(0,colonPos);
// XXX Skip LWS (what is LWS)
int i = colonPos + 1;
while ( isLWS(line.charAt(i)) ) i++;
String value= line.substring(i);
log("<"+head+">=<"+ value+">");
if ( head.equalsIgnoreCase("Location") )
{
// res.setStatus(HttpServletResponse.SC_MOVED_TEMPORARILY);
// res.setHeader(head, value );
log("Location cutted: "+value);
}
else if ( head.equalsIgnoreCase( "Content-type" ) )
res.setContentType( value );
else if ( head.equalsIgnoreCase( "Content-length" ) )
{
try
{
int cLen = Integer.parseInt( value );
retval = ( cLen > 0 );
res.setContentLength(cLen);
}
catch ( NumberFormatException ignore ) {}
}
// Generically treat unknown headers
else
{
log("^- generic.");
res.setHeader(head, value );
}
}
// XXX We do not treat multiline continuation Headers here
// which have not occured anywhere yet.
firstline = false;
}
}
catch ( IOException e )
{
log("Header skip problem:");
throw new ServletException("Header skip problem: "+e.getMessage());
}
log( "--------------" );
return retval;
}
/**
* Read a RFC2616 line from an InputStream:
*/
public int readLine(InputStream in, byte[] b )
throws IOException
{
int off2 = 0;
while ( off2 < b.length )
{
int r = in.read();
if ( r == -1 )
{
if (off2 == 0 )
return -1;
break;
}
if ( r == 13 )
continue;
if ( r == 10 )
break;
b[off2] = (byte) r;
++off2;
}
return off2;
}
/** Copy a file from in to out.
* Sub-classes can override this in order to do filtering of some sort.
*/
public void copyStream( InputStream in, OutputStream out )
throws IOException
{
BufferedInputStream bin = new BufferedInputStream(in);
int b;
while ( ( b = bin.read() ) != -1 )
out.write(b);
}
/**
* Split a blank separated string into
*/
public String[] wordStr( String inp )
{
StringTokenizer tok = new StringTokenizer(inp, " ");
int i, n = tok.countTokens();
String[] res = new String[n];
for(i = 0; i < n; i++ )
res[i] = tok.nextToken();
return res;
}
/**
* XXX Should identify RFC2616 LWS
*/
protected boolean isLWS(char c)
{
return c == ' ';
}
/**
* Capture awaay the standard servlet log ..
*/
public void log(String msg)
{
if ( debugFlag )
System.err.println("## "+msg);
}
}
package net.edwardstx;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileUploadException;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.apache.commons.httpclient.Header;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpMethod;
import org.apache.commons.httpclient.NameValuePair;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.httpclient.methods.PostMethod;
import org.apache.commons.httpclient.methods.multipart.ByteArrayPartSource;
import org.apache.commons.httpclient.methods.multipart.FilePart;
import org.apache.commons.httpclient.methods.multipart.MultipartRequestEntity;
import org.apache.commons.httpclient.methods.multipart.Part;
import org.apache.commons.httpclient.methods.multipart.StringPart;
public class ProxyServlet extends HttpServlet {
/**
* Serialization UID.
*/
private static final long serialVersionUID = 1L;
/**
* Key for redirect location header.
*/
private static final String STRING_LOCATION_HEADER = "Location";
/**
* Key for content type header.
*/
private static final String STRING_CONTENT_TYPE_HEADER_NAME = "Content-Type";
/**
* Key for content length header.
*/
private static final String STRING_CONTENT_LENGTH_HEADER_NAME = "Content-Length";
/**
* Key for host header
*/
private static final String STRING_HOST_HEADER_NAME = "Host";
/**
* The directory to use to temporarily store uploaded files
*/
private static final File FILE_UPLOAD_TEMP_DIRECTORY = new File(System.getProperty("java.io.tmpdir"));
// Proxy host params
/**
* The host to which we are proxying requests
*/
private String stringProxyHost;
/**
* The port on the proxy host to wihch we are proxying requests. Default value is 80.
*/
private int intProxyPort = 80;
/**
* The (optional) path on the proxy host to wihch we are proxying requests. Default value is "".
*/
private String stringProxyPath = "";
/**
* The maximum size for uploaded files in bytes. Default value is 5MB.
*/
private int intMaxFileUploadSize = 5 * 1024 * 1024;
/**
* Initialize the <code>ProxyServlet</code>
* @param servletConfig The Servlet configuration passed in by the servlet conatiner
*/
public void init(ServletConfig servletConfig) {
// Get the proxy host
String stringProxyHostNew = servletConfig.getInitParameter("proxyHost");
if(stringProxyHostNew == null || stringProxyHostNew.length() == 0) {
throw new IllegalArgumentException("Proxy host not set, please set init-param 'proxyHost' in web.xml");
}
this.setProxyHost(stringProxyHostNew);
// Get the proxy port if specified
String stringProxyPortNew = servletConfig.getInitParameter("proxyPort");
if(stringProxyPortNew != null && stringProxyPortNew.length() > 0) {
this.setProxyPort(Integer.parseInt(stringProxyPortNew));
}
// Get the proxy path if specified
String stringProxyPathNew = servletConfig.getInitParameter("proxyPath");
if(stringProxyPathNew != null && stringProxyPathNew.length() > 0) {
this.setProxyPath(stringProxyPathNew);
}
// Get the maximum file upload size if specified
String stringMaxFileUploadSize = servletConfig.getInitParameter("maxFileUploadSize");
if(stringMaxFileUploadSize != null && stringMaxFileUploadSize.length() > 0) {
this.setMaxFileUploadSize(Integer.parseInt(stringMaxFileUploadSize));
}
}
/**
* Performs an HTTP GET request
* @param httpServletRequest The {@link HttpServletRequest} object passed
* in by the servlet engine representing the
* client request to be proxied
* @param httpServletResponse The {@link HttpServletResponse} object by which
* we can send a proxied response to the client
*/
public void doGet (HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse)
throws IOException, ServletException {
// Create a GET request
GetMethod getMethodProxyRequest = new GetMethod(this.getProxyURL(httpServletRequest));
// Forward the request headers
setProxyRequestHeaders(httpServletRequest, getMethodProxyRequest);
// Execute the proxy request
this.executeProxyRequest(getMethodProxyRequest, httpServletRequest, httpServletResponse);
}
/**
* Performs an HTTP POST request
* @param httpServletRequest The {@link HttpServletRequest} object passed
* in by the servlet engine representing the
* client request to be proxied
* @param httpServletResponse The {@link HttpServletResponse} object by which
* we can send a proxied response to the client
*/
public void doPost(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse)
throws IOException, ServletException {
// Create a standard POST request
PostMethod postMethodProxyRequest = new PostMethod(this.getProxyURL(httpServletRequest));
// Forward the request headers
setProxyRequestHeaders(httpServletRequest, postMethodProxyRequest);
// Check if this is a mulitpart (file upload) POST
if(ServletFileUpload.isMultipartContent(httpServletRequest)) {
this.handleMultipartPost(postMethodProxyRequest, httpServletRequest);
} else {
this.handleStandardPost(postMethodProxyRequest, httpServletRequest);
}
// Execute the proxy request
this.executeProxyRequest(postMethodProxyRequest, httpServletRequest, httpServletResponse);
}
/**
* Sets up the given {@link PostMethod} to send the same multipart POST
* data as was sent in the given {@link HttpServletRequest}
* @param postMethodProxyRequest The {@link PostMethod} that we are
* configuring to send a multipart POST request
* @param httpServletRequest The {@link HttpServletRequest} that contains
* the mutlipart POST data to be sent via the {@link PostMethod}
*/
@SuppressWarnings("unchecked")
private void handleMultipartPost(PostMethod postMethodProxyRequest, HttpServletRequest httpServletRequest)
throws ServletException {
// Create a factory for disk-based file items
DiskFileItemFactory diskFileItemFactory = new DiskFileItemFactory();
// Set factory constraints
diskFileItemFactory.setSizeThreshold(this.getMaxFileUploadSize());
diskFileItemFactory.setRepository(FILE_UPLOAD_TEMP_DIRECTORY);
// Create a new file upload handler
ServletFileUpload servletFileUpload = new ServletFileUpload(diskFileItemFactory);
// Parse the request
try {
// Get the multipart items as a list
List<FileItem> listFileItems = (List<FileItem>) servletFileUpload.parseRequest(httpServletRequest);
// Create a list to hold all of the parts
List<Part> listParts = new ArrayList<Part>();
// Iterate the multipart items list
for(FileItem fileItemCurrent : listFileItems) {
// If the current item is a form field, then create a string part
if (fileItemCurrent.isFormField()) {
StringPart stringPart = new StringPart(
fileItemCurrent.getFieldName(), // The field name
fileItemCurrent.getString() // The field value
);
// Add the part to the list
listParts.add(stringPart);
} else {
// The item is a file upload, so we create a FilePart
FilePart filePart = new FilePart(
fileItemCurrent.getFieldName(), // The field name
new ByteArrayPartSource(
fileItemCurrent.getName(), // The uploaded file name
fileItemCurrent.get() // The uploaded file contents
)
);
// Add the part to the list
listParts.add(filePart);
}
}
MultipartRequestEntity multipartRequestEntity = new MultipartRequestEntity(
listParts.toArray(new Part[] {}),
postMethodProxyRequest.getParams()
);
postMethodProxyRequest.setRequestEntity(multipartRequestEntity);
// The current content-type header (received from the client) IS of
// type "multipart/form-data", but the content-type header also
// contains the chunk boundary string of the chunks. Currently, this
// header is using the boundary of the client request, since we
// blindly copied all headers from the client request to the proxy
// request. However, we are creating a new request with a new chunk
// boundary string, so it is necessary that we re-set the
// content-type string to reflect the new chunk boundary string
postMethodProxyRequest.setRequestHeader(STRING_CONTENT_TYPE_HEADER_NAME, multipartRequestEntity.getContentType());
} catch (FileUploadException fileUploadException) {
throw new ServletException(fileUploadException);
}
}
/**
* Sets up the given {@link PostMethod} to send the same standard POST
* data as was sent in the given {@link HttpServletRequest}
* @param postMethodProxyRequest The {@link PostMethod} that we are
* configuring to send a standard POST request
* @param httpServletRequest The {@link HttpServletRequest} that contains
* the POST data to be sent via the {@link PostMethod}
*/
@SuppressWarnings("unchecked")
private void handleStandardPost(PostMethod postMethodProxyRequest, HttpServletRequest httpServletRequest) {
// Get the client POST data as a Map
Map<String, String[]> mapPostParameters = (Map<String,String[]>) httpServletRequest.getParameterMap();
// Create a List to hold the NameValuePairs to be passed to the PostMethod
List<NameValuePair> listNameValuePairs = new ArrayList<NameValuePair>();
// Iterate the parameter names
for(String stringParameterName : mapPostParameters.keySet()) {
// Iterate the values for each parameter name
String[] stringArrayParameterValues = mapPostParameters.get(stringParameterName);
for(String stringParamterValue : stringArrayParameterValues) {
// Create a NameValuePair and store in list
NameValuePair nameValuePair = new NameValuePair(stringParameterName, stringParamterValue);
listNameValuePairs.add(nameValuePair);
}
}
// Set the proxy request POST data
postMethodProxyRequest.setRequestBody(listNameValuePairs.toArray(new NameValuePair[] { }));
}
/**
* Executes the {@link HttpMethod} passed in and sends the proxy response
* back to the client via the given {@link HttpServletResponse}
* @param httpMethodProxyRequest An object representing the proxy request to be made
* @param httpServletResponse An object by which we can send the proxied
* response back to the client
* @throws IOException Can be thrown by the {@link HttpClient}.executeMethod
* @throws ServletException Can be thrown to indicate that another error has occurred
*/
private void executeProxyRequest(
HttpMethod httpMethodProxyRequest,
HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse)
throws IOException, ServletException {
// Create a default HttpClient
HttpClient httpClient = new HttpClient();
httpMethodProxyRequest.setFollowRedirects(false);
// Execute the request
int intProxyResponseCode = httpClient.executeMethod(httpMethodProxyRequest);
// Check if the proxy response is a redirect
// The following code is adapted from org.tigris.noodle.filters.CheckForRedirect
// Hooray for open source software
if(intProxyResponseCode >= HttpServletResponse.SC_MULTIPLE_CHOICES /* 300 */
&& intProxyResponseCode < HttpServletResponse.SC_NOT_MODIFIED /* 304 */) {
String stringStatusCode = Integer.toString(intProxyResponseCode);
String stringLocation = httpMethodProxyRequest.getResponseHeader(STRING_LOCATION_HEADER).getValue();
if(stringLocation == null) {
throw new ServletException("Recieved status code: " + stringStatusCode
+ " but no " + STRING_LOCATION_HEADER + " header was found in the response");
}
// Modify the redirect to go to this proxy servlet rather that the proxied host
String stringMyHostName = httpServletRequest.getServerName();
if(httpServletRequest.getServerPort() != 80) {
stringMyHostName += ":" + httpServletRequest.getServerPort();
}
stringMyHostName += httpServletRequest.getContextPath();
httpServletResponse.sendRedirect(stringLocation.replace(getProxyHostAndPort() + this.getProxyPath(), stringMyHostName));
return;
} else if(intProxyResponseCode == HttpServletResponse.SC_NOT_MODIFIED) {
// 304 needs special handling. See:
// http://www.ics.uci.edu/pub/ietf/http/rfc1945.html#Code304
// We get a 304 whenever passed an 'If-Modified-Since'
// header and the data on disk has not changed; server
// responds w/ a 304 saying I'm not going to send the
// body because the file has not changed.
httpServletResponse.setIntHeader(STRING_CONTENT_LENGTH_HEADER_NAME, 0);
httpServletResponse.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
return;
}
// Pass the response code back to the client
httpServletResponse.setStatus(intProxyResponseCode);
// Pass response headers back to the client
Header[] headerArrayResponse = httpMethodProxyRequest.getResponseHeaders();
for(Header header : headerArrayResponse) {
httpServletResponse.setHeader(header.getName(), header.getValue());
}
// Send the content to the client
InputStream inputStreamProxyResponse = httpMethodProxyRequest.getResponseBodyAsStream();
BufferedInputStream bufferedInputStream = new BufferedInputStream(inputStreamProxyResponse);
OutputStream outputStreamClientResponse = httpServletResponse.getOutputStream();
int intNextByte;
while ( ( intNextByte = bufferedInputStream.read() ) != -1 ) {
outputStreamClientResponse.write(intNextByte);
}
}
public String getServletInfo() {
return "Jason's Proxy Servlet";
}
/**
* Retreives all of the headers from the servlet request and sets them on
* the proxy request
*
* @param httpServletRequest The request object representing the client's
* request to the servlet engine
* @param httpMethodProxyRequest The request that we are about to send to
* the proxy host
*/
@SuppressWarnings("unchecked")
private void setProxyRequestHeaders(HttpServletRequest httpServletRequest, HttpMethod httpMethodProxyRequest) {
// Get an Enumeration of all of the header names sent by the client
Enumeration enumerationOfHeaderNames = httpServletRequest.getHeaderNames();
while(enumerationOfHeaderNames.hasMoreElements()) {
String stringHeaderName = (String) enumerationOfHeaderNames.nextElement();
if(stringHeaderName.equalsIgnoreCase(STRING_CONTENT_LENGTH_HEADER_NAME))
continue;
// As per the Java Servlet API 2.5 documentation:
// Some headers, such as Accept-Language can be sent by clients
// as several headers each with a different value rather than
// sending the header as a comma separated list.
// Thus, we get an Enumeration of the header values sent by the client
Enumeration enumerationOfHeaderValues = httpServletRequest.getHeaders(stringHeaderName);
while(enumerationOfHeaderValues.hasMoreElements()) {
String stringHeaderValue = (String) enumerationOfHeaderValues.nextElement();
// In case the proxy host is running multiple virtual servers,
// rewrite the Host header to ensure that we get content from
// the correct virtual server
if(stringHeaderName.equalsIgnoreCase(STRING_HOST_HEADER_NAME)){
stringHeaderValue = getProxyHostAndPort();
}
Header header = new Header(stringHeaderName, stringHeaderValue);
// Set the same header on the proxy request
httpMethodProxyRequest.setRequestHeader(header);
}
}
}
// Accessors
private String getProxyURL(HttpServletRequest httpServletRequest) {
// Set the protocol to HTTP
String stringProxyURL = "http://" + this.getProxyHostAndPort();
// Check if we are proxying to a path other that the document root
if(!this.getProxyPath().equalsIgnoreCase("")){
stringProxyURL += this.getProxyPath();
}
// Handle the path given to the servlet
stringProxyURL += httpServletRequest.getPathInfo();
// Handle the query string
if(httpServletRequest.getQueryString() != null) {
stringProxyURL += "?" + httpServletRequest.getQueryString();
}
return stringProxyURL;
}
private String getProxyHostAndPort() {
if(this.getProxyPort() == 80) {
return this.getProxyHost();
} else {
return this.getProxyHost() + ":" + this.getProxyPort();
}
}
private String getProxyHost() {
return this.stringProxyHost;
}
private void setProxyHost(String stringProxyHostNew) {
this.stringProxyHost = stringProxyHostNew;
}
private int getProxyPort() {
return this.intProxyPort;
}
private void setProxyPort(int intProxyPortNew) {
this.intProxyPort = intProxyPortNew;
}
private String getProxyPath() {
return this.stringProxyPath;
}
private void setProxyPath(String stringProxyPathNew) {
this.stringProxyPath = stringProxyPathNew;
}
private int getMaxFileUploadSize() {
return this.intMaxFileUploadSize;
}
private void setMaxFileUploadSize(int intMaxFileUploadSizeNew) {
this.intMaxFileUploadSize = intMaxFileUploadSizeNew;
}
}
package org.mitre.dsmiley.httpproxy;
/**
* Copyright MITRE
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.AbortableHttpRequest;
import org.apache.http.client.params.ClientPNames;
import org.apache.http.client.params.CookiePolicy;
import org.apache.http.client.utils.URIUtils;
import org.apache.http.entity.InputStreamEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.message.BasicHeader;
import org.apache.http.message.BasicHttpEntityEnclosingRequest;
import org.apache.http.message.BasicHttpRequest;
import org.apache.http.message.HeaderGroup;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpParams;
import org.apache.http.util.EntityUtils;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
import java.lang.reflect.Constructor;
import java.net.HttpCookie;
import java.net.URI;
import java.util.BitSet;
import java.util.Enumeration;
import java.util.Formatter;
import java.util.List;
/**
* An HTTP reverse proxy/gateway servlet. It is designed to be extended for customization
* if desired. Most of the work is handled by
* <a href="http://hc.apache.org/httpcomponents-client-ga/">Apache HttpClient</a>.
* <p>
* There are alternatives to a servlet based proxy such as Apache mod_proxy if that is available to you. However
* this servlet is easily customizable by Java, secure-able by your web application's security (e.g. spring-security),
* portable across servlet engines, and is embeddable into another web application.
* </p>
* <p>
* Inspiration: http://httpd.apache.org/docs/2.0/mod/mod_proxy.html
* </p>
*
* @author David Smiley dsmiley@mitre.org
*/
public class ProxyServlet extends HttpServlet {
/* INIT PARAMETER NAME CONSTANTS */
/** A boolean parameter name to enable logging of input and target URLs to the servlet log. */
public static final String P_LOG = "log";
/** A boolean parameter name to enable forwarding of the client IP */
public static final String P_FORWARDEDFOR = "forwardip";
/** The parameter name for the target (destination) URI to proxy to. */
protected static final String P_TARGET_URI = "targetUri";
protected static final String ATTR_TARGET_URI =
ProxyServlet.class.getSimpleName() + ".targetUri";
protected static final String ATTR_TARGET_HOST =
ProxyServlet.class.getSimpleName() + ".targetHost";
/* MISC */
protected boolean doLog = false;
protected boolean doForwardIP = true;
/** User agents shouldn't send the url fragment but what if it does? */
protected boolean doSendUrlFragment = true;
//These next 3 are cached here, and should only be referred to in initialization logic. See the
// ATTR_* parameters.
/** From the configured parameter "targetUri". */
protected String targetUri;
protected URI targetUriObj;//new URI(targetUri)
protected HttpHost targetHost;//URIUtils.extractHost(targetUriObj);
private HttpClient proxyClient;
@Override
public String getServletInfo() {
return "A proxy servlet by David Smiley, dsmiley@apache.org";
}
protected String getTargetUri(HttpServletRequest servletRequest) {
return (String) servletRequest.getAttribute(ATTR_TARGET_URI);
}
private HttpHost getTargetHost(HttpServletRequest servletRequest) {
return (HttpHost) servletRequest.getAttribute(ATTR_TARGET_HOST);
}
/**
* Reads a configuration parameter. By default it reads servlet init parameters but
* it can be overridden.
*/
protected String getConfigParam(String key) {
return getServletConfig().getInitParameter(key);
}
@Override
public void init() throws ServletException {
String doLogStr = getConfigParam(P_LOG);
if (doLogStr != null) {
this.doLog = Boolean.parseBoolean(doLogStr);
}
String doForwardIPString = getConfigParam(P_FORWARDEDFOR);
if (doForwardIPString != null) {
this.doForwardIP = Boolean.parseBoolean(doForwardIPString);
}
initTarget();//sets target*
HttpParams hcParams = new BasicHttpParams();
hcParams.setParameter(ClientPNames.COOKIE_POLICY, CookiePolicy.IGNORE_COOKIES);
readConfigParam(hcParams, ClientPNames.HANDLE_REDIRECTS, Boolean.class);
proxyClient = createHttpClient(hcParams);
}
protected void initTarget() throws ServletException {
targetUri = getConfigParam(P_TARGET_URI);
if (targetUri == null)
throw new ServletException(P_TARGET_URI+" is required.");
//test it's valid
try {
targetUriObj = new URI(targetUri);
} catch (Exception e) {
throw new ServletException("Trying to process targetUri init parameter: "+e,e);
}
targetHost = URIUtils.extractHost(targetUriObj);
}
/** Called from {@link #init(javax.servlet.ServletConfig)}. HttpClient offers many opportunities
* for customization. By default,
* <a href="http://hc.apache.org/httpcomponents-client-ga/httpclient/apidocs/org/apache/http/impl/client/SystemDefaultHttpClient.html">
* SystemDefaultHttpClient</a> is used if available, otherwise it falls
* back to:
* <pre>new DefaultHttpClient(new ThreadSafeClientConnManager(),hcParams)</pre>
* SystemDefaultHttpClient uses PoolingClientConnectionManager. In any case, it should be thread-safe. */
@SuppressWarnings({"unchecked", "deprecation"})
protected HttpClient createHttpClient(HttpParams hcParams) {
try {
//as of HttpComponents v4.2, this class is better since it uses System
// Properties:
Class clientClazz = Class.forName("org.apache.http.impl.client.SystemDefaultHttpClient");
Constructor constructor = clientClazz.getConstructor(HttpParams.class);
return (HttpClient) constructor.newInstance(hcParams);
} catch (ClassNotFoundException e) {
//no problem; use v4.1 below
} catch (Exception e) {
throw new RuntimeException(e);
}
//Fallback on using older client:
return new DefaultHttpClient(new ThreadSafeClientConnManager(), hcParams);
}
/** The http client used.
* @see #createHttpClient(HttpParams) */
protected HttpClient getProxyClient() {
return proxyClient;
}
/** Reads a servlet config parameter by the name {@code hcParamName} of type {@code type}, and
* set it in {@code hcParams}.
*/
protected void readConfigParam(HttpParams hcParams, String hcParamName, Class type) {
String val_str = getConfigParam(hcParamName);
if (val_str == null)
return;
Object val_obj;
if (type == String.class) {
val_obj = val_str;
} else {
try {
//noinspection unchecked
val_obj = type.getMethod("valueOf",String.class).invoke(type,val_str);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
hcParams.setParameter(hcParamName,val_obj);
}
@Override
public void destroy() {
//As of HttpComponents v4.3, clients implement closeable
if (proxyClient instanceof Closeable) {//TODO AutoCloseable in Java 1.6
try {
((Closeable) proxyClient).close();
} catch (IOException e) {
log("While destroying servlet, shutting down HttpClient: "+e, e);
}
} else {
//Older releases require we do this:
if (proxyClient != null)
proxyClient.getConnectionManager().shutdown();
}
super.destroy();
}
@Override
protected void service(HttpServletRequest servletRequest, HttpServletResponse servletResponse)
throws ServletException, IOException {
//initialize request attributes from caches if unset by a subclass by this point
if (servletRequest.getAttribute(ATTR_TARGET_URI) == null) {
servletRequest.setAttribute(ATTR_TARGET_URI, targetUri);
}
if (servletRequest.getAttribute(ATTR_TARGET_HOST) == null) {
servletRequest.setAttribute(ATTR_TARGET_HOST, targetHost);
}
// Make the Request
//note: we won't transfer the protocol version because I'm not sure it would truly be compatible
String method = servletRequest.getMethod();
String proxyRequestUri = rewriteUrlFromRequest(servletRequest);
HttpRequest proxyRequest;
//spec: RFC 2616, sec 4.3: either of these two headers signal that there is a message body.
if (servletRequest.getHeader(HttpHeaders.CONTENT_LENGTH) != null ||
servletRequest.getHeader(HttpHeaders.TRANSFER_ENCODING) != null) {
HttpEntityEnclosingRequest eProxyRequest = new BasicHttpEntityEnclosingRequest(method, proxyRequestUri);
// Add the input entity (streamed)
// note: we don't bother ensuring we close the servletInputStream since the container handles it
eProxyRequest.setEntity(new InputStreamEntity(servletRequest.getInputStream(), servletRequest.getContentLength()));
proxyRequest = eProxyRequest;
} else
proxyRequest = new BasicHttpRequest(method, proxyRequestUri);
copyRequestHeaders(servletRequest, proxyRequest);
setXForwardedForHeader(servletRequest, proxyRequest);
HttpResponse proxyResponse = null;
try {
// Execute the request
if (doLog) {
log("proxy " + method + " uri: " + servletRequest.getRequestURI() + " -- " + proxyRequest.getRequestLine().getUri());
}
proxyResponse = proxyClient.execute(getTargetHost(servletRequest), proxyRequest);
// Process the response
int statusCode = proxyResponse.getStatusLine().getStatusCode();
if (doResponseRedirectOrNotModifiedLogic(servletRequest, servletResponse, proxyResponse, statusCode)) {
//the response is already "committed" now without any body to send
//TODO copy response headers?
return;
}
// Pass the response code. This method with the "reason phrase" is deprecated but it's the only way to pass the
// reason along too.
//noinspection deprecation
servletResponse.setStatus(statusCode, proxyResponse.getStatusLine().getReasonPhrase());
copyResponseHeaders(proxyResponse, servletRequest, servletResponse);
// Send the content to the client
copyResponseEntity(proxyResponse, servletResponse);
} catch (Exception e) {
//abort request, according to best practice with HttpClient
if (proxyRequest instanceof AbortableHttpRequest) {
AbortableHttpRequest abortableHttpRequest = (AbortableHttpRequest) proxyRequest;
abortableHttpRequest.abort();
}
if (e instanceof RuntimeException)
throw (RuntimeException)e;
if (e instanceof ServletException)
throw (ServletException)e;
//noinspection ConstantConditions
if (e instanceof IOException)
throw (IOException) e;
throw new RuntimeException(e);
} finally {
// make sure the entire entity was consumed, so the connection is released
if (proxyResponse != null)
consumeQuietly(proxyResponse.getEntity());
//Note: Don't need to close servlet outputStream:
// http://stackoverflow.com/questions/1159168/should-one-call-close-on-httpservletresponse-getoutputstream-getwriter
}
}
protected boolean doResponseRedirectOrNotModifiedLogic(
HttpServletRequest servletRequest, HttpServletResponse servletResponse,
HttpResponse proxyResponse, int statusCode)
throws ServletException, IOException {
// Check if the proxy response is a redirect
// The following code is adapted from org.tigris.noodle.filters.CheckForRedirect
if (statusCode >= HttpServletResponse.SC_MULTIPLE_CHOICES /* 300 */
&& statusCode < HttpServletResponse.SC_NOT_MODIFIED /* 304 */) {
Header locationHeader = proxyResponse.getLastHeader(HttpHeaders.LOCATION);
if (locationHeader == null) {
throw new ServletException("Received status code: " + statusCode
+ " but no " + HttpHeaders.LOCATION + " header was found in the response");
}
// Modify the redirect to go to this proxy servlet rather that the proxied host
String locStr = rewriteUrlFromResponse(servletRequest, locationHeader.getValue());
servletResponse.sendRedirect(locStr);
return true;
}
// 304 needs special handling. See:
// http://www.ics.uci.edu/pub/ietf/http/rfc1945.html#Code304
// We get a 304 whenever passed an 'If-Modified-Since'
// header and the data on disk has not changed; server
// responds w/ a 304 saying I'm not going to send the
// body because the file has not changed.
if (statusCode == HttpServletResponse.SC_NOT_MODIFIED) {
servletResponse.setIntHeader(HttpHeaders.CONTENT_LENGTH, 0);
servletResponse.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
return true;
}
return false;
}
protected void closeQuietly(Closeable closeable) {
try {
closeable.close();
} catch (IOException e) {
log(e.getMessage(), e);
}
}
/** HttpClient v4.1 doesn't have the
* {@link org.apache.http.util.EntityUtils#consumeQuietly(org.apache.http.HttpEntity)} method. */
protected void consumeQuietly(HttpEntity entity) {
try {
EntityUtils.consume(entity);
} catch (IOException e) {//ignore
log(e.getMessage(), e);
}
}
/** These are the "hop-by-hop" headers that should not be copied.
* http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html
* I use an HttpClient HeaderGroup class instead of Set<String> because this
* approach does case insensitive lookup faster.
*/
protected static final HeaderGroup hopByHopHeaders;
static {
hopByHopHeaders = new HeaderGroup();
String[] headers = new String[] {
"Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization",
"TE", "Trailers", "Transfer-Encoding", "Upgrade" };
for (String header : headers) {
hopByHopHeaders.addHeader(new BasicHeader(header, null));
}
}
/** Copy request headers from the servlet client to the proxy request. */
protected void copyRequestHeaders(HttpServletRequest servletRequest, HttpRequest proxyRequest) {
// Get an Enumeration of all of the header names sent by the client
Enumeration enumerationOfHeaderNames = servletRequest.getHeaderNames();
while (enumerationOfHeaderNames.hasMoreElements()) {
String headerName = (String) enumerationOfHeaderNames.nextElement();
//Instead the content-length is effectively set via InputStreamEntity
if (headerName.equalsIgnoreCase(HttpHeaders.CONTENT_LENGTH))
continue;
if (hopByHopHeaders.containsHeader(headerName))
continue;
Enumeration headers = servletRequest.getHeaders(headerName);
while (headers.hasMoreElements()) {//sometimes more than one value
String headerValue = (String) headers.nextElement();
// In case the proxy host is running multiple virtual servers,
// rewrite the Host header to ensure that we get content from
// the correct virtual server
if (headerName.equalsIgnoreCase(HttpHeaders.HOST)) {
HttpHost host = getTargetHost(servletRequest);
headerValue = host.getHostName();
if (host.getPort() != -1)
headerValue += ":"+host.getPort();
} else if (headerName.equalsIgnoreCase(org.apache.http.cookie.SM.COOKIE)) {
headerValue = getRealCookie(headerValue);
}
proxyRequest.addHeader(headerName, headerValue);
}
}
}
private void setXForwardedForHeader(HttpServletRequest servletRequest,
HttpRequest proxyRequest) {
String headerName = "X-Forwarded-For";
if (doForwardIP) {
String newHeader = servletRequest.getRemoteAddr();
String existingHeader = servletRequest.getHeader(headerName);
if (existingHeader != null) {
newHeader = existingHeader + ", " + newHeader;
}
proxyRequest.setHeader(headerName, newHeader);
}
}
/** Copy proxied response headers back to the servlet client. */
protected void copyResponseHeaders(HttpResponse proxyResponse, HttpServletRequest servletRequest,
HttpServletResponse servletResponse) {
for (Header header : proxyResponse.getAllHeaders()) {
if (hopByHopHeaders.containsHeader(header.getName()))
continue;
if (header.getName().equalsIgnoreCase(org.apache.http.cookie.SM.SET_COOKIE) ||
header.getName().equalsIgnoreCase(org.apache.http.cookie.SM.SET_COOKIE2)) {
copyProxyCookie(servletRequest, servletResponse, header);
} else {
servletResponse.addHeader(header.getName(), header.getValue());
}
}
}
/** Copy cookie from the proxy to the servlet client.
* Replaces cookie path to local path and renames cookie to avoid collisions.
*/
protected void copyProxyCookie(HttpServletRequest servletRequest,
HttpServletResponse servletResponse, Header header) {
List<HttpCookie> cookies = HttpCookie.parse(header.getValue());
String path = servletRequest.getContextPath(); // path starts with / or is empty string
path += servletRequest.getServletPath(); // servlet path starts with / or is empty string
for (HttpCookie cookie : cookies) {
//set cookie name prefixed w/ a proxy value so it won't collide w/ other cookies
String proxyCookieName = getCookieNamePrefix() + cookie.getName();
Cookie servletCookie = new Cookie(proxyCookieName, cookie.getValue());
servletCookie.setComment(cookie.getComment());
servletCookie.setMaxAge((int) cookie.getMaxAge());
servletCookie.setPath(path); //set to the path of the proxy servlet
// don't set cookie domain
servletCookie.setSecure(cookie.getSecure());
servletCookie.setVersion(cookie.getVersion());
servletResponse.addCookie(servletCookie);
}
}
/** Take any client cookies that were originally from the proxy and prepare them to send to the
* proxy. This relies on cookie headers being set correctly according to RFC 6265 Sec 5.4.
* This also blocks any local cookies from being sent to the proxy.
*/
protected String getRealCookie(String cookieValue) {
StringBuilder escapedCookie = new StringBuilder();
String cookies[] = cookieValue.split("; ");
for (String cookie : cookies) {
String cookieSplit[] = cookie.split("=");
if (cookieSplit.length == 2) {
String cookieName = cookieSplit[0];
if (cookieName.startsWith(getCookieNamePrefix())) {
cookieName = cookieName.substring(getCookieNamePrefix().length());
if (escapedCookie.length() > 0) {
escapedCookie.append("; ");
}
escapedCookie.append(cookieName).append("=").append(cookieSplit[1]);
}
}
cookieValue = escapedCookie.toString();
}
return cookieValue;
}
/** The string prefixing rewritten cookies. */
protected String getCookieNamePrefix() {
return "!Proxy!" + getServletConfig().getServletName();
}
/** Copy response body data (the entity) from the proxy to the servlet client. */
protected void copyResponseEntity(HttpResponse proxyResponse, HttpServletResponse servletResponse) throws IOException {
HttpEntity entity = proxyResponse.getEntity();
if (entity != null) {
OutputStream servletOutputStream = servletResponse.getOutputStream();
entity.writeTo(servletOutputStream);
}
}
/** Reads the request URI from {@code servletRequest} and rewrites it, considering targetUri.
* It's used to make the new request.
*/
protected String rewriteUrlFromRequest(HttpServletRequest servletRequest) {
StringBuilder uri = new StringBuilder(500);
uri.append(getTargetUri(servletRequest));
// Handle the path given to the servlet
if (servletRequest.getPathInfo() != null) {//ex: /my/path.html
uri.append(encodeUriQuery(servletRequest.getPathInfo()));
}
// Handle the query string & fragment
String queryString = servletRequest.getQueryString();//ex:(following '?'): name=value&foo=bar#fragment
String fragment = null;
//split off fragment from queryString, updating queryString if found
if (queryString != null) {
int fragIdx = queryString.indexOf('#');
if (fragIdx >= 0) {
fragment = queryString.substring(fragIdx + 1);
queryString = queryString.substring(0,fragIdx);
}
}
queryString = rewriteQueryStringFromRequest(servletRequest, queryString);
if (queryString != null && queryString.length() > 0) {
uri.append('?');
uri.append(encodeUriQuery(queryString));
}
if (doSendUrlFragment && fragment != null) {
uri.append('#');
uri.append(encodeUriQuery(fragment));
}
return uri.toString();
}
protected String rewriteQueryStringFromRequest(HttpServletRequest servletRequest, String queryString) {
return queryString;
}
/** For a redirect response from the target server, this translates {@code theUrl} to redirect to
* and translates it to one the original client can use. */
protected String rewriteUrlFromResponse(HttpServletRequest servletRequest, String theUrl) {
//TODO document example paths
final String targetUri = getTargetUri(servletRequest);
if (theUrl.startsWith(targetUri)) {
String curUrl = servletRequest.getRequestURL().toString();//no query
String pathInfo = servletRequest.getPathInfo();
if (pathInfo != null) {
assert curUrl.endsWith(pathInfo);
curUrl = curUrl.substring(0,curUrl.length()-pathInfo.length());//take pathInfo off
}
theUrl = curUrl+theUrl.substring(targetUri.length());
}
return theUrl;
}
/** The target URI as configured. Not null. */
public String getTargetUri() { return targetUri; }
/**
* Encodes characters in the query or fragment part of the URI.
*
* <p>Unfortunately, an incoming URI sometimes has characters disallowed by the spec. HttpClient
* insists that the outgoing proxied request has a valid URI because it uses Java's {@link URI}.
* To be more forgiving, we must escape the problematic characters. See the URI class for the
* spec.
*
* @param in example: name=value&foo=bar#fragment
*/
protected static CharSequence encodeUriQuery(CharSequence in) {
//Note that I can't simply use URI.java to encode because it will escape pre-existing escaped things.
StringBuilder outBuf = null;
Formatter formatter = null;
for(int i = 0; i < in.length(); i++) {
char c = in.charAt(i);
boolean escape = true;
if (c < 128) {
if (asciiQueryChars.get((int)c)) {
escape = false;
}
} else if (!Character.isISOControl(c) && !Character.isSpaceChar(c)) {//not-ascii
escape = false;
}
if (!escape) {
if (outBuf != null)
outBuf.append(c);
} else {
//escape
if (outBuf == null) {
outBuf = new StringBuilder(in.length() + 5*3);
outBuf.append(in,0,i);
formatter = new Formatter(outBuf);
}
//leading %, 0 padded, width 2, capital hex
formatter.format("%%%02X",(int)c);//TODO
}
}
return outBuf != null ? outBuf : in;
}
protected static final BitSet asciiQueryChars;
static {
char[] c_unreserved = "_-!.~'()*".toCharArray();//plus alphanum
char[] c_punct = ",;:$&+=".toCharArray();
char[] c_reserved = "?/[]@".toCharArray();//plus punct
asciiQueryChars = new BitSet(128);
for(char c = 'a'; c <= 'z'; c++) asciiQueryChars.set((int)c);
for(char c = 'A'; c <= 'Z'; c++) asciiQueryChars.set((int)c);
for(char c = '0'; c <= '9'; c++) asciiQueryChars.set((int)c);
for(char c : c_unreserved) asciiQueryChars.set((int)c);
for(char c : c_punct) asciiQueryChars.set((int)c);
for(char c : c_reserved) asciiQueryChars.set((int)c);
asciiQueryChars.set((int)'%');//leave existing percent escapes in place
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment