PHP 构造 multipart/form-data 格式 POST 请求体的方法

  • 2019 年 11 月 24 日
  • 笔记

引言

最近在尝试基于 PHP 做一个反向代理 HTTP 的程序,其中一个需求是将程序收到的HTTP请求还原回 RFC2616 的原始格式。

在处理的过程中遇到的问题主要在请求体的处理上。利用PHP的封装协议机制,我们可以通过读取 php://input 访问原始的POST信息。但这种方式有一个局限,对于 multipart/form-data 的请求来说,为了支持文件上传的操作,PHP会预先把请求体中的文件暂存到临时文件夹,并把参数解析到变量 $_POST$_FILES 中, php://input 获取原始请求的功能也随之失效。

Stack Overflow 上的相关问题给出的 解决办法 是修改服务器配置,把发到 PHP 脚本的 Content-Type: multipart/form-data; boundary=xxxx 修改为其它格式,使其不经过PHP的 form-data 解析;或是把 php.ini 配置关于POST数据解析的 enable_post_data_reading = Off 选项关闭。然而这两种方法并不非常具有普遍性,在某些PHP配置文件不可控的共享主机的环境下并不适用。

于是引出了本文讨论的话题 — 如何重新组装 multipart/form-data 格式的原始 POST 请求体。

multipart/form-data 格式

在POST请求中,一般表单会通过 application/x-www-form-urlencoded 格式上传,但此格式的数据仅支持文本格式,不支持二进制文件的上传。为了支持表单 POST 文件上传,RFC1867 定义了 multipart/form-data 的数据格式,实现了通过POST请求上传表单的内容以及二进制文件数据,关于数据的形态,参考 四种常见的 POST 提交数据方式 | JerryQu 的小站

RFC1867 对于 multipart/form-data 的数据格式主要在MIME RFC1521 7.2.1 小节定义的。另外,在MIME 标准 Media Types 部分 RFC2046 的 5.1.1 节中,对于 multipart-body 的格式有一个较为清晰的 BNF 范式的语法定义,简短总结如下(来自 Stack Overflow) :

multipart-body := [preamble CRLF]                    dash-boundary CRLF                    body-part *encapsulation                    close-delimiter                    [CRLF epilogue]    dash-boundary := "--" boundary    body-part := MIME-part-headers [CRLF *OCTET]    encapsulation := delimiter                   CRLF body-part    delimiter := CRLF dash-boundary    close-delimiter := delimiter "--"

还原 multipart/form-data 的代码

写代码前搜索前人的经验,在 SegmentFault 看到了一位前辈的实现,参考前辈的代码,以及 RFC2046 的 BNF 语法定义,写了以下代码:

// 还原 rfc1867, rfc2046 格式的FormData  function getFormData() {    // body-part array    $body = array();      // 普通参数    foreach ($_POST as $key => $value) {      $body_part = "Content-Disposition: form-data; name="$key"rn";      $body_part .= "rn$value";      $body[] = $body_part;    }      // 上传文件处理    foreach ($_FILES as $key => $value) {      $body_part = "Content-Disposition: form-data; name="$key"; filename="{$value['name']}"rn";      $body_part .= "Content-type: {$value['type']}rn";      $body_part .= "rn".file_get_contents($value['tmp_name']);      $body[] = $body_part;    }      // 提取boundary    $boundary = substr($_SERVER['CONTENT_TYPE'], strpos($_SERVER['CONTENT_TYPE'], "=") + 1);    // multipart-body    $multipart_body = "--$boundaryrn";    // 拼接各个域    $multipart_body .= implode("rn--$boundaryrn", $body);    // 最后一个不同的 boundary    $multipart_body .= "rn--$boundary--";      return $multipart_body;  }

数组类型参数的支持

以上代码在大多数情况下工作正常,但未考虑到请求参数的类型为数组的情况。

在PHP解释器源码的测试用例中,我们可以找到许多数组类型参数的测试,部分摘录如下:

a[]=1  a[]=1&a[]=1  a[]=1&a[0]=5  a[a]=1&a[b]=3  a[]=1&a[a]=1&a[b]=3  a[][]=1&a[][]=3&b[a][b][c]=1&b[a][b][d]=1  a=1&b=ZYX&c[][][][][][][][][][][][][][][][][][][][][][]=123&d=123&e[][]][]=3
Content-Type: multipart/form-data; boundary=---------------------------20896060251896012921717172737  -----------------------------20896060251896012921717172737  Content-Disposition: form-data; name="file[]"; filename="file1.txt"  Content-Type: text/plain-file1    1  -----------------------------20896060251896012921717172737  Content-Disposition: form-data; name="file[2]"; filename="file2.txt"  Content-Type: text/plain-file2    2  -----------------------------20896060251896012921717172737  Content-Disposition: form-data; name="file[]"; filename="file3.txt"  Content-Type: text/plain-file3    3  -----------------------------20896060251896012921717172737--

在PHP源码的 main/php_variables.c 中的 php_register_variable_ex 函数中,我们可以看到相关的处理:

/* 99-110行 */  /* ensure that we don't have spaces or dots in the variable name (not binary safe) */  for (p = var; *p; p++) {      if (*p == ' ' || *p == '.') {          *p='_';      } else if (*p == '[') {          is_array = 1;          ip = p;          *p = 0;          break;      }  }  var_len = p - var;    /* 229-235行 */  ip++;  if (*ip == '[') {      is_array = 1;      *ip = 0;  } else {      goto plain_var;  }

可见,在还原POST数据的时候,我们还需要考虑到参数为数组的情况。

这里通过一个简单的 DFS 算法深度优先遍历数组,生成类似 a[0], a[1][1] 的字符串来实现:

<?php    $arr = [    'key1' => [      '_key1' => 23333,      '_key2' => 66666,    ],    'key2' => "hahah",    "test",  ];    var_dump($arr);    function dfs(&$node, $prefix, &$result) {    if (!is_array($node)) {      $result[$prefix] = $node;    } else {      foreach ($node as $key => $value) {        dfs($value, "{$prefix}[{$key}]", $result);      }    }  }    dfs($arr, "arr", $result);    foreach ($result as $key => $value) {    echo "$key = $valuen";  }

运行结果:

array(3) {    ["key1"]=>    array(2) {      ["_key1"]=>      int(23333)      ["_key2"]=>      int(66666)    }    ["key2"]=>    string(5) "hahah"    [0]=>    string(4) "test"  }  arr[key1][_key1] = 23333  arr[key1][_key2] = 66666  arr[key2] = hahah  arr[0] = test

至于 $_FILES 数组,这里有一个反直觉的情况,具体在文档中也有人提出: PHP: POST method uploads – Manual

简单地说,当表单中文件域的key为数组形式时,拿到的 $_FILES 数组类似如下的格式:

array(1) {    ["key"]=>    array(5) {      ["name"]=>      array(2) {        [0]=>        string(8) "test.txt"        [1]=>        array(1) {          [0]=>          string(8) "test.txt"        }      }      ["type"]=>      array(2) {        [0]=>        string(10) "text/plain"        [1]=>        array(1) {          [0]=>          string(10) "text/plain"        }      }      ["tmp_name"]=>      array(2) {        [0]=>        string(14) "/tmp/phpKHCoSt"        [1]=>        array(1) {          [0]=>          string(14) "/tmp/phpSgtRHe"        }      }      ["error"]=>      array(2) {        [0]=>        int(0)        [1]=>        array(1) {          [0]=>          int(0)        }      }      ["size"]=>      array(2) {        [0]=>        int(8)        [1]=>        array(1) {          [0]=>          int(8)        }      }    }  }

假设我的目标是 key[1][0] 的 name 属性,在PHP中我们需要通过 $_FILES["key"]["name"][1][0] 来访问,而在 $_FILES["key"]["name"] 中,后面的索引的层级并不确定的,我们也不能简单地指定 [1][0] 来访问 $_FILES["key"]["name"][1][0]。所以这里得有一些 hack 来优化一下这个过程,这里我实现了一个 query_multidimensional_array 函数,具体看最终的代码。

getFormData() 代码实现

以下是整个函数的完整实现:

// 还原 rfc1867, rfc2046 格式的FormData  function getFormData() {    // body-part array    $body = array();      // 普通参数    foreach ($_POST as $key => $value) {      if (!is_array($value)) {        $body_part = "Content-Disposition: form-data; name="$key"rn";        $body_part .= "rn$value";        $body[] = $body_part;      } else {        // 数组的情况处理 如 param1[]=xxxx        $result = array();        convert_array_key($value, $key, $result);        foreach ($result as $k => $v) {          $body_part = "Content-Disposition: form-data; name="$k"rn";          $body_part .= "rn$v";          $body[] = $body_part;        }      }    }      // 上传文件处理    foreach ($_FILES as $key => $value) {      if (!is_array($value['type'])) {        $body_part = "Content-Disposition: form-data; name="$key"; filename="{$value['name']}"rn";        $body_part .= "Content-type: {$value['type']}rn";        $body_part .= "rn".file_get_contents($value['tmp_name']);        $body[] = $body_part;      } else {        // 文件key是数组的情况 如 file1[]=xxxx        $result = array();        convert_array_key($value['type'], "", $result);        foreach ($result as $k => $v) {          $filename = query_multidimensional_array($value['name'], $k);          $type = query_multidimensional_array($value['type'], $k);          $tmp_name = query_multidimensional_array($value['tmp_name'], $k);          $body_part = "Content-Disposition: form-data; name="{$key}{$k}"; filename="{$filename}"rn";          $body_part .= "Content-type: {$type}rn";          $body_part .= "rn".file_get_contents($tmp_name);          $body[] = $body_part;        }      }    }      // 提取boundary    $boundary = substr($_SERVER['CONTENT_TYPE'], strpos($_SERVER['CONTENT_TYPE'], "=") + 1);    // multipart-body    $multipart_body = "--$boundaryrn";    // 拼接各个域    $multipart_body .= implode("rn--$boundaryrn", $body);    // 最后一个不同的 boundary    $multipart_body .= "rn--$boundary--";      return $multipart_body;  }    // 直接访问多维数组元素  // query: [0][0] -> $array[0][0]  function query_multidimensional_array(&$array, $query) {    $query = explode('][', substr($query, 1, -1));    $temp = $array;    foreach ($query as $key) {      $temp = $temp[$key];    }    return $temp;  }    // DFS将数组变为一维形式  function convert_array_key(&$node, $prefix, &$result) {    if (!is_array($node)) {      $result[$prefix] = $node;    } else {      foreach ($node as $key => $value) {        convert_array_key($value, "{$prefix}[{$key}]", $result);      }    }  }

至此,在PHP脚本中,只需调用 getFormData() ,即可获得 multipart/form-data 请求的原始数据,通过以下代码可以实现一键获取请求原始POST Body。

需要注意的是,若数组类型参数是 a[] 这种形式,经过本函数还原后会补充具体的下标,比如说这里的 a[] 会被处理成 a[0]a[][] 则为 a[0][0]。从而导致了 POST Body 长度发生变化,若结果需要用于发包等操作,我们需要重新计算 Content-Length ,避免请求出现问题。

if (@$_SERVER['CONTENT_TYPE'] && strpos($_SERVER['CONTENT_TYPE'], "multipart/form-data") !== false) {    $body = getFormData();    $content_length = strlen($body);  } else {    $body = file_get_contents('php://input');  }

参考