SOLR-7217: autodetect POST body for curl

git-svn-id: https://svn.apache.org/repos/asf/lucene/dev/trunk@1667457 13f79535-47bb-0310-9956-ffa450edef68
This commit is contained in:
Yonik Seeley 2015-03-18 05:56:13 +00:00
parent bf00c1a0a7
commit 695a0f0af0
6 changed files with 314 additions and 78 deletions

View File

@ -179,6 +179,10 @@ New Features
* SOLR-6141: Schema API: Remove fields, dynamic fields, field types and copy
fields; and replace fields, dynamic fields and field types. (Steve Rowe)
* SOLR-7217: HTTP POST body is auto-detected when the client is curl and the content
type is form data (curl's default), allowing users to use curl to send
JSON or XML without having to specify the content type. (yonik)
Bug Fixes
----------------------

View File

@ -65,7 +65,7 @@ import org.slf4j.LoggerFactory;
public class SolrRequestParsers
{
final Logger log = LoggerFactory.getLogger(SolrRequestParsers.class);
final static Logger log = LoggerFactory.getLogger(SolrRequestParsers.class);
// Should these constants be in a more public place?
public static final String MULTIPART = "multipart";
@ -325,7 +325,7 @@ public class SolrRequestParsers
// we already have a charsetDecoder, so we can directly decode without buffering:
final String key = decodeChars(keyBytes, keyPos, charsetDecoder),
value = decodeChars(valueBytes, valuePos, charsetDecoder);
MultiMapSolrParams.addParam(key, value, map);
MultiMapSolrParams.addParam(key.trim(), value, map);
}
} else if (valueStream.size() > 0) {
throw new SolrException(ErrorCode.BAD_REQUEST, "application/x-www-form-urlencoded invalid: missing key");
@ -398,7 +398,7 @@ public class SolrRequestParsers
it.remove();
final Long valuePos = (Long) it.next();
it.remove();
MultiMapSolrParams.addParam(decodeChars(keyBytes, keyPos.longValue(), charsetDecoder),
MultiMapSolrParams.addParam(decodeChars(keyBytes, keyPos.longValue(), charsetDecoder).trim(),
decodeChars(valueBytes, valuePos.longValue(), charsetDecoder), map);
}
}
@ -569,7 +569,7 @@ public class SolrRequestParsers
// If it's a form field, put it in our parameter map
if (item.isFormField()) {
MultiMapSolrParams.addParam(
item.getFieldName(),
item.getFieldName().trim(),
item.getString(), params.getMap() );
}
// Add the stream
@ -587,44 +587,40 @@ public class SolrRequestParsers
*/
static class FormDataRequestParser implements SolrRequestParser
{
private static final long WS_MASK=(1L<<' ')|(1L<<'\t')|(1L<<'\r')|(1L<<'\n')|(1L<<'#')|(1L<<'/')|(0x01); // set 1 bit so 0xA0 will be flagged as possible whitespace
private final int uploadLimitKB;
public FormDataRequestParser( int limit )
{
uploadLimitKB = limit;
}
@Override
public SolrParams parseParamsAndFillStreams(
final HttpServletRequest req, ArrayList<ContentStream> streams ) throws Exception
{
if (!isFormData(req)) {
throw new SolrException( ErrorCode.BAD_REQUEST, "Not application/x-www-form-urlencoded content: "+req.getContentType() );
}
public SolrParams parseParamsAndFillStreams(HttpServletRequest req, ArrayList<ContentStream> streams, InputStream in) throws Exception {
final Map<String,String[]> map = new HashMap<>();
// also add possible URL parameters and include into the map (parsed using UTF-8):
final String qs = req.getQueryString();
if (qs != null) {
parseQueryString(qs, map);
}
// may be -1, so we check again later. But if it's already greater we can stop processing!
final long totalLength = req.getContentLength();
final long maxLength = ((long) uploadLimitKB) * 1024L;
if (totalLength > maxLength) {
throw new SolrException(ErrorCode.BAD_REQUEST, "application/x-www-form-urlencoded content length (" +
totalLength + " bytes) exceeds upload limit of " + uploadLimitKB + " KB");
totalLength + " bytes) exceeds upload limit of " + uploadLimitKB + " KB");
}
// get query String from request body, using the charset given in content-type:
final String cs = ContentStreamBase.getCharsetFromContentType(req.getContentType());
final Charset charset = (cs == null) ? StandardCharsets.UTF_8 : Charset.forName(cs);
InputStream in = null;
try {
in = req.getInputStream();
final long bytesRead = parseFormDataContent(FastInputStream.wrap(in), maxLength, charset, map, false);
in = FastInputStream.wrap( in == null ? req.getInputStream() : in);
final long bytesRead = parseFormDataContent(in, maxLength, charset, map, false);
if (bytesRead == 0L && totalLength > 0L) {
throw getParameterIncompatibilityException();
}
@ -635,11 +631,21 @@ public class SolrRequestParsers
} finally {
IOUtils.closeWhileHandlingException(in);
}
return new MultiMapSolrParams(map);
}
private SolrException getParameterIncompatibilityException() {
@Override
public SolrParams parseParamsAndFillStreams(HttpServletRequest req, ArrayList<ContentStream> streams ) throws Exception {
if (!isFormData(req)) {
throw new SolrException(ErrorCode.BAD_REQUEST, "Not application/x-www-form-urlencoded content: " + req.getContentType());
}
return parseParamsAndFillStreams(req, streams, null);
}
public static SolrException getParameterIncompatibilityException() {
return new SolrException(ErrorCode.SERVER_ERROR,
"Solr requires that request parameters sent using application/x-www-form-urlencoded " +
"content-type can be read through the request input stream. Unfortunately, the " +
@ -673,35 +679,168 @@ public class SolrRequestParsers
MultipartRequestParser multipart;
RawRequestParser raw;
FormDataRequestParser formdata;
StandardRequestParser(MultipartRequestParser multi, RawRequestParser raw, FormDataRequestParser formdata)
{
this.multipart = multi;
this.raw = raw;
this.formdata = formdata;
}
@Override
public SolrParams parseParamsAndFillStreams(
final HttpServletRequest req, ArrayList<ContentStream> streams ) throws Exception
{
String method = req.getMethod().toUpperCase(Locale.ROOT);
if ("GET".equals(method) || "HEAD".equals(method)
|| (("PUT".equals(method) || "DELETE".equals(method))
&& (req.getRequestURI().contains("/schema")
|| req.getRequestURI().contains("/config")))) {
return parseQueryString(req.getQueryString());
}
if ("POST".equals( method ) ) {
if (formdata.isFormData(req)) {
return formdata.parseParamsAndFillStreams(req, streams);
public SolrParams parseParamsAndFillStreams(final HttpServletRequest req, ArrayList<ContentStream> streams ) throws Exception {
String contentType = req.getContentType();
String method = req.getMethod(); // No need to uppercase... HTTP verbs are case sensitive
String uri = req.getRequestURI();
boolean isPost = "POST".equals(method);
// SOLR-6787 changed the behavior of a POST without content type. Previously it would throw an exception,
// but now it will use the raw request parser.
/***
if (contentType == null && isPost) {
throw new SolrException(ErrorCode.UNSUPPORTED_MEDIA_TYPE, "Must specify a Content-Type header with POST requests");
}
***/
// According to previous StandardRequestParser logic (this is a re-written version),
// POST was handled normally, but other methods (PUT/DELETE)
// were handled by restlet if the URI contained /schema or /config
// "handled by restlet" means that we don't attempt to handle any request body here.
if (!isPost) {
if (contentType == null) {
return parseQueryString(req.getQueryString());
}
if (ServletFileUpload.isMultipartContent(req)) {
return multipart.parseParamsAndFillStreams(req, streams);
// OK, we have a BODY at this point
boolean restletPath = false;
int idx = uri.indexOf("/schema");
if (idx >= 0 && uri.endsWith("/schema") || uri.contains("/schema/")) {
restletPath = true;
}
idx = uri.indexOf("/config");
if (idx >= 0 && uri.endsWith("/config") || uri.contains("/config/")) {
restletPath = true;
}
if (restletPath) {
return parseQueryString(req.getQueryString());
}
if ("PUT".equals(method) || "DELETE".equals(method)) {
throw new SolrException(ErrorCode.BAD_REQUEST, "Unsupported method: " + method + " for request " + req);
}
return raw.parseParamsAndFillStreams(req, streams);
}
throw new SolrException(ErrorCode.BAD_REQUEST, "Unsupported method: " + method + " for request " + req);
if (formdata.isFormData(req)) {
String userAgent = req.getHeader("User-Agent");
boolean isCurl = userAgent != null && userAgent.startsWith("curl/");
FastInputStream input = FastInputStream.wrap( req.getInputStream() );
if (isCurl) {
SolrParams params = autodetect(req, streams, input);
if (params != null) return params;
}
return formdata.parseParamsAndFillStreams(req, streams, input);
}
if (ServletFileUpload.isMultipartContent(req)) {
return multipart.parseParamsAndFillStreams(req, streams);
}
// some other content-type (json, XML, csv, etc)
return raw.parseParamsAndFillStreams(req, streams);
}
}
private static final long WS_MASK=(1L<<' ')|(1L<<'\t')|(1L<<'\r')|(1L<<'\n')|(1L<<'#')|(1L<<'/')|(0x01); // set 1 bit so 0xA0 will be flagged as possible whitespace
/** Returns the parameter map if a different content type was auto-detected */
private static SolrParams autodetect(HttpServletRequest req, ArrayList<ContentStream> streams, FastInputStream in) throws IOException {
String detectedContentType = null;
boolean shouldClose = true;
try {
in.peek(); // should cause some bytes to be read
byte[] arr = in.getBuffer();
int pos = in.getPositionInBuffer();
int end = in.getEndInBuffer();
for (int i = pos; i < end - 1; i++) { // we do "end-1" because we check "arr[i+1]" sometimes in the loop body
int ch = arr[i];
boolean isWhitespace = ((WS_MASK >> ch) & 0x01) != 0 && (ch <= ' ' || ch == 0xa0);
if (!isWhitespace) {
// first non-whitespace chars
if (ch == '#' // single line comment
|| (ch == '/' && (arr[i + 1] == '/' || arr[i + 1] == '*')) // single line or multi-line comment
|| (ch == '{' || ch == '[') // start of JSON object
)
{
detectedContentType = "application/json";
}
if (ch == '<') {
detectedContentType = "text/xml";
}
break;
}
}
if (detectedContentType == null) {
shouldClose = false;
return null;
}
Long size = null;
String v = req.getHeader("Content-Length");
if (v != null) {
size = Long.valueOf(v);
}
streams.add(new InputStreamContentStream(in, detectedContentType, size));
final Map<String, String[]> map = new HashMap<>();
// also add possible URL parameters and include into the map (parsed using UTF-8):
final String qs = req.getQueryString();
if (qs != null) {
parseQueryString(qs, map);
}
return new MultiMapSolrParams(map);
} catch (IOException ioe) {
throw new SolrException(ErrorCode.BAD_REQUEST, ioe);
} catch (IllegalStateException ise) {
throw (SolrException) FormDataRequestParser.getParameterIncompatibilityException().initCause(ise);
} finally {
if (shouldClose) {
IOUtils.closeWhileHandlingException(in);
}
}
}
/**
* Wrap InputStream as a ContentStream
*/
static class InputStreamContentStream extends ContentStreamBase {
private final InputStream is;
public InputStreamContentStream(InputStream is, String detectedContentType, Long size ) {
this.is = is;
this.contentType = detectedContentType;
this.size = size;
}
@Override
public InputStream getStream() throws IOException {
return is;
}
}
}

View File

@ -223,11 +223,9 @@ public class SolrRequestParserTest extends SolrTestCaseJ4 {
};
for( String contentType : ct ) {
HttpServletRequest request = createMock(HttpServletRequest.class);
HttpServletRequest request = getMock("/solr/select", contentType, postBytes.length);
expect(request.getMethod()).andReturn("POST").anyTimes();
expect(request.getContentType()).andReturn( contentType ).anyTimes();
expect(request.getQueryString()).andReturn(getParams).anyTimes();
expect(request.getContentLength()).andReturn(postBytes.length).anyTimes();
expect(request.getInputStream()).andReturn(new ByteServletInputStream(postBytes));
replay(request);
@ -285,11 +283,9 @@ public class SolrRequestParserTest extends SolrTestCaseJ4 {
final String contentType = "application/x-www-form-urlencoded; charset=iso-8859-1";
// Set up the expected behavior
HttpServletRequest request = createMock(HttpServletRequest.class);
HttpServletRequest request = getMock("/solr/select", contentType, postBytes.length);
expect(request.getMethod()).andReturn("POST").anyTimes();
expect(request.getContentType()).andReturn( contentType ).anyTimes();
expect(request.getQueryString()).andReturn(getParams).anyTimes();
expect(request.getContentLength()).andReturn(postBytes.length).anyTimes();
expect(request.getInputStream()).andReturn(new ByteServletInputStream(postBytes));
replay(request);
@ -316,11 +312,8 @@ public class SolrRequestParserTest extends SolrTestCaseJ4 {
while (large.length() <= limitKBytes * 1024) {
large.append('&').append(large);
}
HttpServletRequest request = createMock(HttpServletRequest.class);
HttpServletRequest request = getMock("/solr/select", "application/x-www-form-urlencoded", -1);
expect(request.getMethod()).andReturn("POST").anyTimes();
expect(request.getContentType()).andReturn("application/x-www-form-urlencoded").anyTimes();
// we dont pass a content-length to let the security mechanism limit it:
expect(request.getContentLength()).andReturn(-1).anyTimes();
expect(request.getQueryString()).andReturn(null).anyTimes();
expect(request.getInputStream()).andReturn(new ByteServletInputStream(large.toString().getBytes(StandardCharsets.US_ASCII)));
replay(request);
@ -338,10 +331,7 @@ public class SolrRequestParserTest extends SolrTestCaseJ4 {
@Test
public void testParameterIncompatibilityException1() throws Exception
{
HttpServletRequest request = createMock(HttpServletRequest.class);
expect(request.getMethod()).andReturn("POST").anyTimes();
expect(request.getContentType()).andReturn("application/x-www-form-urlencoded").anyTimes();
expect(request.getContentLength()).andReturn(100).anyTimes();
HttpServletRequest request = getMock("/solr/select", "application/x-www-form-urlencoded", 100);
expect(request.getQueryString()).andReturn(null).anyTimes();
// we emulate Jetty that returns empty stream when parameters were parsed before:
expect(request.getInputStream()).andReturn(new ServletInputStream() {
@ -377,10 +367,8 @@ public class SolrRequestParserTest extends SolrTestCaseJ4 {
@Test
public void testParameterIncompatibilityException2() throws Exception
{
HttpServletRequest request = createMock(HttpServletRequest.class);
HttpServletRequest request = getMock("/solr/select", "application/x-www-form-urlencoded", 100);
expect(request.getMethod()).andReturn("POST").anyTimes();
expect(request.getContentType()).andReturn("application/x-www-form-urlencoded").anyTimes();
expect(request.getContentLength()).andReturn(100).anyTimes();
expect(request.getQueryString()).andReturn(null).anyTimes();
// we emulate Tomcat that throws IllegalStateException when parameters were parsed before:
expect(request.getInputStream()).andThrow(new IllegalStateException());
@ -398,9 +386,8 @@ public class SolrRequestParserTest extends SolrTestCaseJ4 {
@Test
public void testAddHttpRequestToContext() throws Exception {
HttpServletRequest request = createMock(HttpServletRequest.class);
HttpServletRequest request = getMock("/solr/select", null, -1);
expect(request.getMethod()).andReturn("GET").anyTimes();
expect(request.getContentType()).andReturn( "application/x-www-form-urlencoded" ).anyTimes();
expect(request.getQueryString()).andReturn("q=title:solr").anyTimes();
Map<String, String> headers = new HashMap<>();
headers.put("X-Forwarded-For", "10.0.0.1");
@ -410,7 +397,6 @@ public class SolrRequestParserTest extends SolrTestCaseJ4 {
v.add(entry.getValue());
expect(request.getHeaders(entry.getKey())).andReturn(v.elements()).anyTimes();
}
expect(request.getAttribute(SolrRequestParsers.REQUEST_TIMER_SERVLET_ATTRIBUTE)).andReturn(null).anyTimes();
replay(request);
SolrRequestParsers parsers = new SolrRequestParsers(h.getCore().getSolrConfig());
@ -426,19 +412,99 @@ public class SolrRequestParserTest extends SolrTestCaseJ4 {
}
public void testPostMissingContentType() throws Exception {
HttpServletRequest request = createMock(HttpServletRequest.class);
HttpServletRequest request = getMock();
expect(request.getMethod()).andReturn("POST").anyTimes();
expect(request.getContentType()).andReturn(null).anyTimes();
expect(request.getQueryString()).andReturn(null).anyTimes();
expect(request.getHeader(anyObject())).andReturn(null).anyTimes();
expect(request.getAttribute(SolrRequestParsers.REQUEST_TIMER_SERVLET_ATTRIBUTE)).andReturn(null).anyTimes();
replay(request);
SolrRequestParsers parsers = new SolrRequestParsers(h.getCore().getSolrConfig());
try {
parsers.parse(h.getCore(), "/select", request);
} catch (SolrException e) {
log.error("should not throw SolrException", e);
fail("should not throw SolrException");
}
}
@Test
public void testAutoDetect() throws Exception {
String curl = "curl/7.30.0";
for (String method : new String[]{"GET","POST"}) {
doAutoDetect(null, method, "{}=a", null, "{}", "a"); // unknown agent should not auto-detect
doAutoDetect(curl, method, "{}", "application/json", null, null); // curl should auto-detect
doAutoDetect(curl, method, " \t\n\r {} ", "application/json", null, null); // starting with whitespace
doAutoDetect(curl, method, " \t\n\r // how now brown cow\n {} ", "application/json", null, null); // supporting comments
doAutoDetect(curl, method, " \t\n\r #different style comment\n {} ", "application/json", null, null);
doAutoDetect(curl, method, " \t\n\r /* C style comment */\n {} ", "application/json", null, null);
doAutoDetect(curl, method, " \t\n\r <tag>hi</tag> ", "text/xml", null, null);
doAutoDetect(curl, method, " \t\r\n aaa=1&bbb=2&ccc=3", null, "bbb", "2"); // params with whitespace first
doAutoDetect(curl, method, "/x=foo&aaa=1&bbb=2&ccc=3", null, "/x", "foo"); // param name that looks like a path
doAutoDetect(curl, method, " \t\r\n /x=foo&aaa=1&bbb=2&ccc=3", null, "bbb", "2"); // param name that looks like a path
}
}
public void doAutoDetect(String userAgent, String method, final String body, String expectedContentType, String expectedKey, String expectedValue) throws Exception {
String uri = "/solr/select";
String contentType = "application/x-www-form-urlencoded";
int contentLength = -1; // does this mean auto-detect?
HttpServletRequest request = createMock(HttpServletRequest.class);
expect(request.getHeader("User-Agent")).andReturn(userAgent).anyTimes();
expect(request.getHeader("Content-Length")).andReturn(null).anyTimes();
expect(request.getRequestURI()).andReturn(uri).anyTimes();
expect(request.getContentType()).andReturn(contentType).anyTimes();
expect(request.getContentLength()).andReturn(contentLength).anyTimes();
expect(request.getAttribute(SolrRequestParsers.REQUEST_TIMER_SERVLET_ATTRIBUTE)).andReturn(null).anyTimes();
expect(request.getMethod()).andReturn(method).anyTimes();
// we dont pass a content-length to let the security mechanism limit it:
expect(request.getQueryString()).andReturn("foo=1&bar=2").anyTimes();
expect(request.getInputStream()).andReturn(new ByteServletInputStream(body.getBytes(StandardCharsets.US_ASCII)));
replay(request);
SolrRequestParsers parsers = new SolrRequestParsers(h.getCore().getSolrConfig());
SolrQueryRequest req = parsers.parse(h.getCore(), "/select", request);
int num=0;
if (expectedContentType != null) {
for (ContentStream cs : req.getContentStreams()) {
num++;
assertTrue(cs.getContentType().startsWith(expectedContentType));
String returnedBody = IOUtils.toString(cs.getReader());
assertEquals(body, returnedBody);
}
assertEquals(1, num);
}
assertEquals("1", req.getParams().get("foo"));
assertEquals("2", req.getParams().get("bar"));
if (expectedKey != null) {
assertEquals(expectedValue, req.getParams().get(expectedKey));
}
req.close();
}
public HttpServletRequest getMock() {
return getMock("/solr/select", null, -1);
// return getMock("/solr/select", "application/x-www-form-urlencoded");
}
public HttpServletRequest getMock(String uri, String contentType, int contentLength) {
HttpServletRequest request = createMock(HttpServletRequest.class);
expect(request.getHeader("User-Agent")).andReturn(null).anyTimes();
expect(request.getRequestURI()).andReturn(uri).anyTimes();
expect(request.getContentType()).andReturn(contentType).anyTimes();
expect(request.getContentLength()).andReturn(contentLength).anyTimes();
expect(request.getAttribute(SolrRequestParsers.REQUEST_TIMER_SERVLET_ATTRIBUTE)).andReturn(null).anyTimes();
return request;
}
}

View File

@ -144,7 +144,7 @@ public class AutoCommitTest extends AbstractSolrTestCase {
{
ArrayList<ContentStream> streams = new ArrayList<>();
ContentStreamBase stream = new ContentStreamBase.StringStream( str );
stream.setContentType( contentType );
if (contentType != null) stream.setContentType( contentType );
streams.add( stream );
return streams;
}

View File

@ -107,6 +107,7 @@ public abstract class ContentStreamBase implements ContentStream
@Override
public String getContentType() {
if(contentType==null) {
// TODO: this is buggy... does not allow for whitespace, JSON comments, etc.
InputStream stream = null;
try {
stream = new FileInputStream(file);
@ -140,29 +141,40 @@ public abstract class ContentStreamBase implements ContentStream
public static class StringStream extends ContentStreamBase
{
private final String str;
public StringStream( String str ) {
this.str = str;
contentType = null;
this(str, detect(str));
}
public StringStream( String str, String contentType ) {
this.str = str;
this.contentType = contentType;
name = null;
size = new Long( str.length() );
sourceInfo = "string";
}
@Override
public String getContentType() {
if(contentType==null && str.length() > 0) {
char first = str.charAt(0);
if(first == '<') {
return "application/xml";
public static String detect(String str) {
String detectedContentType = null;
int lim = str.length() - 1;
for (int i=0; i<lim; i++) {
char ch = str.charAt(i);
if (Character.isWhitespace(ch)) {
continue;
}
if(first == '{') {
return "application/json";
// first non-whitespace chars
if (ch == '#' // single line comment
|| (ch == '/' && (str.charAt(i + 1) == '/' || str.charAt(i + 1) == '*')) // single line or multi-line comment
|| (ch == '{' || ch == '[') // start of JSON object
)
{
detectedContentType = "application/json";
} else if (ch == '<') {
detectedContentType = "text/xml";
}
// find a comma? for CSV?
break;
}
return contentType;
return detectedContentType;
}
@Override

View File

@ -96,6 +96,21 @@ public class FastInputStream extends DataInputInputStream {
return end - pos;
}
/** Returns the internal buffer used for caching */
public byte[] getBuffer() {
return buf;
}
/** Current position within the internal buffer */
public int getPositionInBuffer() {
return pos;
}
/** Current end-of-data position within the internal buffer. This is one past the last valid byte. */
public int getEndInBuffer() {
return end;
}
@Override
public int read(byte b[], int off, int len) throws IOException {
int r=0; // number of bytes we have read