From 1f15fa4796c5fa09f72c0c2c534dac4fcfe2a168 Mon Sep 17 00:00:00 2001
From: Tatsuhiro Tsujikawa <tatsuhiro.t@gmail.com>
Date: Wed, 31 Oct 2012 00:42:07 +0900
Subject: [PATCH] Parse Content-Disposition header field value conforming to
 RFC 6266

This change enables curl to parse filename*= parameter in
Content-Disposition header, which is described in RFC 6266 and
RFC 5987. It also handles quoted characters in filename=
parameter properly.

The filename*= parameter can contain percent-encoded string and
its charset. This implementation does not use charset and does
not unescape percent-encoded string.
---
 src/tool_cb_hdr.c   |  366 ++++++++++++++++++++++++++++++++++++++++++++++-----
 tests/data/test1311 |    2 +-
 tests/data/test1313 |    2 +-
 tests/data/test1340 |    4 +-
 tests/data/test1341 |    4 +-
 5 files changed, 339 insertions(+), 39 deletions(-)

diff --git a/src/tool_cb_hdr.c b/src/tool_cb_hdr.c
index ef340f7..8b619c2 100644
--- a/src/tool_cb_hdr.c
+++ b/src/tool_cb_hdr.c
@@ -35,6 +35,9 @@
 
 static char *parse_filename(const char *ptr, size_t len);
 
+static ssize_t parse_content_disposition(char *dest, size_t destlen,
+                                         const char *in, size_t len);
+
 /*
 ** callback for CURLOPT_HEADERFUNCTION
 */
@@ -46,7 +49,6 @@ size_t tool_header_cb(void *ptr, size_t size, size_t nmemb, void *userdata)
   struct OutStruct *heads = hdrcbdata->heads;
   const char *str = ptr;
   const size_t cb = size * nmemb;
-  const char *end = (char*)ptr + cb;
 
   /*
    * Once that libcurl has called back tool_header_cb() the returned value
@@ -86,31 +88,12 @@ size_t tool_header_cb(void *ptr, size_t size, size_t nmemb, void *userdata)
   if(hdrcbdata->honor_cd_filename &&
      (cb > 20) && checkprefix("Content-disposition:", str)) {
     const char *p = str + 20;
-
-    /* look for the 'filename=' parameter
-       (encoded filenames (*=) are not supported) */
-    for(;;) {
+    ssize_t rv;
+    char rawval[1024];
+    rv = parse_content_disposition(rawval, sizeof(rawval), p, cb - 20);
+    if(rv > 0) {
       char *filename;
-      size_t len;
-
-      while(*p && (p < end) && !ISALPHA(*p))
-        p++;
-      if(p > end - 9)
-        break;
-
-      if(memcmp(p, "filename=", 9)) {
-        /* no match, find next parameter */
-        while((p < end) && (*p != ';'))
-          p++;
-        continue;
-      }
-      p += 9;
-
-      /* this expression below typecasts 'cb' only to avoid
-         warning: signed and unsigned type in conditional expression
-      */
-      len = (ssize_t)cb - (p - str);
-      filename = parse_filename(p, len);
+      filename = parse_filename(rawval, rv);
       if(filename) {
         outs->filename = filename;
         outs->alloc_filename = TRUE;
@@ -119,7 +102,6 @@ size_t tool_header_cb(void *ptr, size_t size, size_t nmemb, void *userdata)
         outs->fopened = FALSE;
         outs->stream = NULL;
         hdrcbdata->honor_cd_filename = FALSE;
-        break;
       }
       else
         return failure;
@@ -129,6 +111,331 @@ size_t tool_header_cb(void *ptr, size_t size, size_t nmemb, void *userdata)
   return cb;
 }
 
+static int is_lws(const char c)
+{
+  /* In strict definition, \r and \n are not included in linear white
+     space, but they are included for convenience because the header
+     field value ends with \r\n. */
+  return c == ' ' || c == '\t' || c == '\r' || c == '\n';
+}
+
+static int in_iso8859_1(unsigned char c)
+{
+  return (0x20u <= c && c <= 0x7eu) || 0xa0u <= c;
+}
+
+static int is_alpha(const char c)
+{
+  return ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z');
+}
+
+static int is_hex(const char c)
+{
+  return ('A' <= c && c <= 'F') || ('a' <= c && c <= 'f') || ISDIGIT(c);
+}
+
+static int in_rfc2616_http_token(const char c)
+{
+  return is_alpha(c) || ISDIGIT(c) ||
+    c == '!' || c == '#' || c == '$' || c == '%' ||
+    c == '&' || c == '\'' || c == '*' || c == '+' ||
+    c == '-' || c == '.' || c == '^' || c == '_' ||
+    c == '`' || c == '|' || c == '~';
+}
+
+static int in_rfc2978_mime_charset(const char c)
+{
+  return is_alpha(c) || ISDIGIT(c) ||
+    c == '!' || c == '#' || c == '$' || c == '%' ||
+    c == '&' || c == '\'' || c == '+' ||
+    c == '-' || c == '^' || c == '_' ||
+    c == '`' || c == '{' || c == '}' || c == '~';
+}
+
+static int in_rfc5987_attr_char(const char c)
+{
+  return in_rfc2616_http_token(c) && c != '*' && c != '\'' && c != '%';
+}
+
+/* Returns nonzero if [first, last) == s in case-insensitive
+   manner. */
+static int strieq(const char *first, const char *last, const char *s)
+{
+  for(; first != last && *s &&
+        Curl_raw_toupper(*first) == Curl_raw_toupper(*s); ++first, ++s);
+  return first == last && !*s;
+}
+
+typedef enum {
+  CD_BEFORE_DISPOSITION_TYPE,
+  CD_AFTER_DISPOSITION_TYPE,
+  CD_DISPOSITION_TYPE,
+  CD_BEFORE_DISPOSITION_PARM_NAME,
+  CD_AFTER_DISPOSITION_PARM_NAME,
+  CD_DISPOSITION_PARM_NAME,
+  CD_BEFORE_VALUE,
+  CD_AFTER_VALUE,
+  CD_QUOTED_STRING,
+  CD_TOKEN,
+  CD_BEFORE_EXT_VALUE,
+  CD_CHARSET,
+  CD_LANGUAGE,
+  CD_VALUE_CHARS,
+  CD_VALUE_CHARS_PCT_ENCODED1,
+  CD_VALUE_CHARS_PCT_ENCODED2
+} content_disposition_parse_state;
+
+typedef enum {
+  CD_FILENAME_FOUND = 1,
+  CD_EXT_FILENAME_FOUND = 1 << 1
+} content_disposition_parse_flag;
+
+static int fnstore(int in_file_parm, char **dpp, size_t *dlenp, char c)
+{
+  if(in_file_parm) {
+    if(*dlenp == 0)
+      return -1;
+    else {
+      *(*dpp)++ = c;
+      --*dlenp;
+    }
+  }
+  return 0;
+}
+
+/*
+** Parses Content-Disposition header field value and stores the
+** filename in dest, whose maximum capacity is destlen. This parser
+** largely conforms to RFC 6266, except that:
+**
+** * it does not check unescaped pct-encoded byte sequence is valid
+**   according to the specified charset when RFC 5987 Encoded is used.
+**
+** * it does not unescape pct-encoded string when RFC 5987 Encoding is
+**   used.
+**
+** This function returns the length of extracted filename in dest if
+** it succeeds, or -1. If dest is not big enough to store filename to
+** be extracted, this function fails.
+**
+*/
+static ssize_t parse_content_disposition(char *dest, size_t destlen,
+                                         const char *in, size_t len)
+{
+  const char *p = in, *eop = in + len, *mark_first = NULL, *mark_last = NULL;
+  int state = CD_BEFORE_DISPOSITION_TYPE;
+  /* becomes nonzero if p is in filename parameter. */
+  int in_file_parm = 0;
+  /* bitwise-OR of content_disposition_parse_flag to indicate which
+     parameter has already been parsed. */
+  int flags = 0;
+  /* becomes nonzero if current character is escaped by '\' in
+     quoted-string. */
+  int quoted_seen = 0;
+  /* To suppress warnings */
+  char *dp = dest;
+  size_t dlen = destlen;
+
+  for(; p != eop; ++p) {
+    switch(state) {
+    case CD_BEFORE_DISPOSITION_TYPE:
+      if(in_rfc2616_http_token(*p))
+        state = CD_DISPOSITION_TYPE;
+      else if(!is_lws(*p))
+        return -1;
+      break;
+    case CD_AFTER_DISPOSITION_TYPE:
+    case CD_DISPOSITION_TYPE:
+      if(*p == ';')
+        state = CD_BEFORE_DISPOSITION_PARM_NAME;
+      else if(is_lws(*p))
+        state = CD_AFTER_DISPOSITION_TYPE;
+      else if(state == CD_AFTER_DISPOSITION_TYPE ||
+              !in_rfc2616_http_token(*p))
+        return -1;
+      break;
+    case CD_BEFORE_DISPOSITION_PARM_NAME:
+      if(in_rfc2616_http_token(*p)) {
+        mark_first = p;
+        state = CD_DISPOSITION_PARM_NAME;
+      }
+      else if(!is_lws(*p))
+        return -1;
+      break;
+    case CD_AFTER_DISPOSITION_PARM_NAME:
+    case CD_DISPOSITION_PARM_NAME:
+      if(*p == '=') {
+        if(state == CD_DISPOSITION_PARM_NAME)
+          mark_last = p;
+        in_file_parm = 0;
+        if(strieq(mark_first, mark_last, "filename*")) {
+          if((flags & CD_EXT_FILENAME_FOUND) == 0)
+            in_file_parm = 1;
+          else
+            return -1;
+          state = CD_BEFORE_EXT_VALUE;
+        }
+        else if(strieq(mark_first, mark_last, "filename")) {
+          if(flags & CD_FILENAME_FOUND)
+            return -1;
+          if((flags & CD_EXT_FILENAME_FOUND) == 0)
+            in_file_parm = 1;
+          state = CD_BEFORE_VALUE;
+        }
+        else {
+          /* ext-token must be characters in token, followed by "*" */
+          if(mark_first != mark_last - 1 && *(mark_last - 1) == '*')
+            state = CD_BEFORE_EXT_VALUE;
+          else
+            state = CD_BEFORE_VALUE;
+        }
+        if(in_file_parm) {
+          dp = dest;
+          dlen = destlen;
+        }
+      }
+      else if(is_lws(*p)) {
+        mark_last = p;
+        state = CD_AFTER_DISPOSITION_PARM_NAME;
+      }
+      else if(state == CD_AFTER_DISPOSITION_PARM_NAME ||
+              !in_rfc2616_http_token(*p)) {
+        return -1;
+      }
+      break;
+    case CD_BEFORE_VALUE:
+      if(*p == '"') {
+        quoted_seen = 0;
+        state = CD_QUOTED_STRING;
+      }
+      else if(in_rfc2616_http_token(*p)) {
+        if(fnstore(in_file_parm, &dp, &dlen, *p) == -1)
+          return -1;
+        state = CD_TOKEN;
+      }
+      else if(!is_lws(*p))
+        return -1;
+      break;
+    case CD_AFTER_VALUE:
+      if(*p == ';')
+        state = CD_BEFORE_DISPOSITION_PARM_NAME;
+      else if(!is_lws(*p))
+        return -1;
+      break;
+    case CD_QUOTED_STRING:
+      if(*p == '\\' && quoted_seen == 0)
+        quoted_seen = 1;
+      else if(*p == '"' && quoted_seen == 0) {
+        if(in_file_parm)
+          flags |= CD_FILENAME_FOUND;
+        state = CD_AFTER_VALUE;
+      }
+      else {
+        /* TEXT which is OCTET except CTLs, but including LWS. We only
+           accept ISO-8859-1 chars. */
+        quoted_seen = 0;
+        if(!in_iso8859_1(*p))
+          return -1;
+        if(fnstore(in_file_parm, &dp, &dlen, *p) == -1)
+          return -1;
+      }
+      break;
+    case CD_TOKEN:
+      if(in_rfc2616_http_token(*p)) {
+        if(fnstore(in_file_parm, &dp, &dlen, *p) == -1)
+          return -1;
+      }
+      else if(*p == ';') {
+        if(in_file_parm)
+          flags |= CD_FILENAME_FOUND;
+        state = CD_BEFORE_DISPOSITION_PARM_NAME;
+      }
+      else if(is_lws(*p)) {
+        if(in_file_parm)
+          flags |= CD_FILENAME_FOUND;
+        state = CD_AFTER_VALUE;
+      }
+      else
+        return -1;
+      break;
+    case CD_BEFORE_EXT_VALUE:
+      if(*p == '\'')
+        /* Empty charset is not allowed */
+        return -1;
+      else if(in_rfc2978_mime_charset(*p))
+        state = CD_CHARSET;
+      else if(!is_lws(*p))
+        return -1;
+      break;
+    case CD_CHARSET:
+      if(*p == '\'')
+        /* We ignore charset for now. */
+        state = CD_LANGUAGE;
+      else if(!in_rfc2978_mime_charset(*p))
+        return -1;
+      break;
+    case CD_LANGUAGE:
+      if(*p == '\'') {
+        if(in_file_parm) {
+          dp = dest;
+          dlen = destlen;
+        }
+        state = CD_VALUE_CHARS;
+      }
+      else if(*p != '-' && !is_alpha(*p) && !ISDIGIT(*p))
+        return -1;
+      break;
+    case CD_VALUE_CHARS:
+      if(in_rfc5987_attr_char(*p)) {
+        if(fnstore(in_file_parm, &dp, &dlen, *p) == -1)
+          return -1;
+      }
+      else if(*p == '%') {
+        if(fnstore(in_file_parm, &dp, &dlen, *p) == -1)
+          return -1;
+        state = CD_VALUE_CHARS_PCT_ENCODED1;
+      }
+      else if(*p == ';' || is_lws(*p)) {
+        if(in_file_parm)
+          flags |= CD_EXT_FILENAME_FOUND;
+        if(*p == ';')
+          state = CD_BEFORE_DISPOSITION_PARM_NAME;
+        else
+          state = CD_AFTER_VALUE;
+      }
+      else if(!in_rfc5987_attr_char(*p))
+        return -1;
+      break;
+    case CD_VALUE_CHARS_PCT_ENCODED1:
+    case CD_VALUE_CHARS_PCT_ENCODED2:
+      /* We just check that "%" is followed by 2 hex bytes so that
+         they forms pct-encoded string properly. */
+      if(is_hex(*p)) {
+        if(fnstore(in_file_parm, &dp, &dlen, *p) == -1)
+          return -1;
+        if(state == CD_VALUE_CHARS_PCT_ENCODED1)
+          state = CD_VALUE_CHARS_PCT_ENCODED2;
+        else
+          state = CD_VALUE_CHARS;
+      }
+      else
+        return -1;
+      break;
+    }
+  }
+  switch(state) {
+  case CD_BEFORE_DISPOSITION_TYPE:
+  case CD_AFTER_DISPOSITION_TYPE:
+  case CD_DISPOSITION_TYPE:
+  case CD_AFTER_VALUE:
+  case CD_TOKEN:
+  case CD_VALUE_CHARS:
+    return destlen - dlen;
+  default:
+    return -1;
+  }
+}
+
 /*
  * Copies a file name part and returns an ALLOCATED data buffer.
  */
@@ -147,13 +454,6 @@ static char *parse_filename(const char *ptr, size_t len)
   copy[len] = '\0';
 
   p = copy;
-  if(*p == '\'' || *p == '"') {
-    /* store the starting quote */
-    stop = *p;
-    p++;
-  }
-  else
-    stop = ';';
 
   /* if the filename contains a path, only use filename portion */
   q = strrchr(copy, '/');
diff --git a/tests/data/test1311 b/tests/data/test1311
index e47647c..9bd7e99 100644
--- a/tests/data/test1311
+++ b/tests/data/test1311
@@ -16,7 +16,7 @@ Server: test-server/fake
 Content-Length: 6
 Connection: close
 Content-Type: text/html
-Content-Disposition: filename=name1311; charset=funny; option=strange
+Content-Disposition: inline; filename=name1311; charset=funny; option=strange
 
 12345
 </data>
diff --git a/tests/data/test1313 b/tests/data/test1313
index 2331ae9..55dcc3f 100644
--- a/tests/data/test1313
+++ b/tests/data/test1313
@@ -56,7 +56,7 @@ Host: %HOSTIP:%HTTPPORT
 Accept: */*
 
 </protocol>
-<file name="log/name1313">
+<file name="log/'name1313">
 12345
 </file>
 
diff --git a/tests/data/test1340 b/tests/data/test1340
index 5153889..1a4a3de 100644
--- a/tests/data/test1340
+++ b/tests/data/test1340
@@ -16,7 +16,7 @@ Server: test-server/fake
 Content-Length: 6
 Connection: close
 Content-Type: text/html
-Content-Disposition: filename=name1340; charset=funny; option=strange
+Content-Disposition: inline; filename=name1340; charset=funny; option=strange
 
 12345
 </data>
@@ -68,7 +68,7 @@ Server: test-server/fake
 Content-Length: 6
 Connection: close
 Content-Type: text/html
-Content-Disposition: filename=name1340; charset=funny; option=strange
+Content-Disposition: inline; filename=name1340; charset=funny; option=strange
 
 </file2>
 
diff --git a/tests/data/test1341 b/tests/data/test1341
index e12ee31..94b8de4 100644
--- a/tests/data/test1341
+++ b/tests/data/test1341
@@ -16,7 +16,7 @@ Server: test-server/fake
 Content-Length: 6
 Connection: close
 Content-Type: text/html
-Content-Disposition: filename=name1341; charset=funny; option=strange
+Content-Disposition: inline; filename=name1341; charset=funny; option=strange
 
 12345
 </data>
@@ -68,7 +68,7 @@ Server: test-server/fake
 Content-Length: 6
 Connection: close
 Content-Type: text/html
-Content-Disposition: filename=name1341; charset=funny; option=strange
+Content-Disposition: inline; filename=name1341; charset=funny; option=strange
 
 curl: Saved to filename '%PWD/log/name1341'
 </file2>
-- 
1.7.10.4

