多部分/表单数据请求的 Indy MIME 解码返回尾随 CR/LF

Posted

技术标签:

【中文标题】多部分/表单数据请求的 Indy MIME 解码返回尾随 CR/LF【英文标题】:Indy MIME decoding of Multipart/Form-Data Requests returns trailing CR/LF 【发布时间】:2015-01-31 04:20:51 【问题描述】:

Indy 10.6 修订版 5128 似乎包含一项更改,该更改破坏了以下 HTTP 表单上传代码。

接收到的数据末尾包含两个额外的字节,一个 CR/LF 对。

阅读 5127 和 5128 之间更改的代码行并没有让我找到根本原因。

我会在找到时间并在此处发布结果时尝试调试它(但也许有人更快)。

这是一个独立的演示应用程序,它在http://127.0.0.1:8080 处显示了一个 html 上传表单

program IndyMultipartUploadDemo;

$APPTYPE CONSOLE

uses
  IdHTTPServer, IdCustomHTTPServer, IdContext, IdSocketHandle, IdGlobal,
  IdMessageCoder, IdGlobalProtocols, IdMessageCoderMIME, IdMultiPartFormData,
  SysUtils, Classes;

type
  TMimeHandler = procedure(var VDecoder: TIdMessageDecoder;
    var VMsgEnd: Boolean; const Response: TIdHTTPResponseInfo) of object;

  TMyServer = class(TIdHTTPServer)
  private
    procedure ProcessMimePart(var VDecoder: TIdMessageDecoder;
      var VMsgEnd: Boolean; const Response: TIdHTTPResponseInfo);
    function IsHeaderMediaType(const AHeaderLine, AMediaType: String): Boolean;
    function MediaTypeMatches(const AValue, AMediaType: String): Boolean;
    function GetUploadFolder: string;
    procedure HandleMultipartUpload(Request: TIdHTTPRequestInfo; Response:
     TIdHTTPResponseInfo; MimeHandler: TMimeHandler);
  public
    procedure InitComponent; override;
    procedure DoCommandGet(AContext: TIdContext;
      ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo); override;
  end;

procedure Demo;
var
  Server: TMyServer;
begin
  ReportMemoryLeaksOnShutdown := True;

  Server := TMyServer.Create;
  try
    try
      Server.Active := True;
    except
      on E: Exception do
      begin
        WriteLn(E.ClassName + ' ' + E.Message);
      end;
    end;
    WriteLn('Hit any key to terminate.');
    ReadLn;
  finally
    Server.Free;
  end;
end;

procedure TMyServer.InitComponent;
var
  Binding: TIdSocketHandle;
begin
  inherited;

  Bindings.Clear;
  Binding := Bindings.Add;
  Binding.IP := '127.0.0.1';
  Binding.Port := 8080;

  KeepAlive := True;
end;

procedure TMyServer.DoCommandGet(AContext: TIdContext;
  ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo);
begin
  AResponseInfo.ContentType := 'text/html';
  AResponseInfo.CharSet := 'UTF-8';

  if ARequestInfo.CommandType = hcGET then
  begin
    AResponseInfo.ContentText :=
        '<!DOCTYPE HTML>' + #13#10
      + '<html>' + #13#10
      + ' <head>' + #13#10
      + ' <title>Multipart Upload Example</title>' + #13#10
      + ' </head>' + #13#10
      + ' <body> ' + #13#10
      + ' <form enctype="multipart/form-data" method="post">' + #13#10
      + ' <fieldset>' + #13#10
      + ' <legend>Standard file upload</legend>' + #13#10
      + ' <label>File input</label>' + #13#10
      + ' <input type="file" class="input-file" name="upload" />' + #13#10
      + ' <button type="submit" class="btn btn-default">Upload</button>' + #13#10
      + ' </fieldset>' + #13#10
      + ' </form>' + #13#10
      + ' </body>' + #13#10
      + '</html>' + #13#10;
  end
  else
  begin
    if ARequestInfo.CommandType = hcPOST then
    begin
      if IsHeaderMediaType(ARequestInfo.ContentType, 'multipart/form-data') then
      begin
        HandleMultipartUpload(ARequestInfo, AResponseInfo, ProcessMimePart);
      end;
    end;
  end;
end;

// based on code on the Indy and Winsock Forum articles
// http://forums2.atozed.com/viewtopic.php?f=7&t=10924
// http://embarcadero.newsgroups.archived.at/public.delphi.internet.winsock/201107/1107276163.html

procedure TMyServer.ProcessMimePart(var VDecoder: TIdMessageDecoder;
  var VMsgEnd: Boolean; const Response: TIdHTTPResponseInfo);
var
  LMStream: TMemoryStream;
  LNewDecoder: TIdMessageDecoder;
  UploadFile: string;
begin
  LMStream := TMemoryStream.Create;
  try
    LNewDecoder := VDecoder.ReadBody(LMStream, VMsgEnd);
    if VDecoder.Filename <> '' then
    begin
      try
        LMStream.Position := 0;
        Response.ContentText := Response.ContentText
          + Format('<p>%s %d bytes</p>' + #13#10,
            [VDecoder.Filename, LMStream.Size]);

        // write stream to upload folder
        UploadFile := GetUploadFolder + VDecoder.Filename;
        LMStream.SaveToFile(UploadFile);
        Response.ContentText := Response.ContentText
          + '<p>' + UploadFile + ' written</p>';

      except
        LNewDecoder.Free;
        raise;
      end;
    end;
    VDecoder.Free;
    VDecoder := LNewDecoder;
  finally
    LMStream.Free;
  end;
end;

function TMyServer.IsHeaderMediaType(const AHeaderLine, AMediaType: String): Boolean;
begin
  Result := MediaTypeMatches(ExtractHeaderItem(AHeaderLine), AMediaType);
end;

function TMyServer.MediaTypeMatches(const AValue, AMediaType: String): Boolean;
begin
  if Pos('/', AMediaType) > 0 then begin
    Result := TextIsSame(AValue, AMediaType);
  end else begin
    Result := TextStartsWith(AValue, AMediaType + '/');
  end;
end;

function TMyServer.GetUploadFolder: string;
begin
  Result := ExtractFilePath(ParamStr(0)) + 'upload\';
  ForceDirectories(Result);
end;

procedure TMyServer.HandleMultipartUpload(Request: TIdHTTPRequestInfo;
  Response: TIdHTTPResponseInfo; MimeHandler: TMimeHandler);
var
  LBoundary, LBoundaryStart, LBoundaryEnd: string;
  LDecoder: TIdMessageDecoder;
  LLine: string;
  LBoundaryFound, LIsStartBoundary, LMsgEnd: Boolean;
begin
  LBoundary := ExtractHeaderSubItem(Request.ContentType, 'boundary',
    QuoteHTTP);
  if LBoundary = '' then
  begin
    Response.ResponseNo := 400;
    Response.CloseConnection := True;
    Response.WriteHeader;
    Exit;
  end;

  LBoundaryStart := '--' + LBoundary;
  LBoundaryEnd := LBoundaryStart + '--';

  LDecoder := TIdMessageDecoderMIME.Create(nil);
  try
    TIdMessageDecoderMIME(LDecoder).MIMEBoundary := LBoundary;
    LDecoder.SourceStream := Request.PostStream;
    LDecoder.FreeSourceStream := False;

    LBoundaryFound := False;
    LIsStartBoundary := False;
    repeat
      LLine := ReadLnFromStream(Request.PostStream, -1, True);
      if LLine = LBoundaryStart then
      begin
        LBoundaryFound := True;
        LIsStartBoundary := True;
      end
      else if LLine = LBoundaryEnd then
      begin
        LBoundaryFound := True;
      end;
    until LBoundaryFound;

    if (not LBoundaryFound) or (not LIsStartBoundary) then
    begin
      Response.ResponseNo := 400;
      Response.CloseConnection := True;
      Response.WriteHeader;
      Exit;
    end;

    LMsgEnd := False;
    repeat
      TIdMessageDecoderMIME(LDecoder).MIMEBoundary := LBoundary;
      LDecoder.SourceStream := Request.PostStream;
      LDecoder.FreeSourceStream := False;

      LDecoder.ReadHeader;
      case LDecoder.PartType of
        mcptText, mcptAttachment:
          begin
            MimeHandler(LDecoder, LMsgEnd, Response);
          end;
        mcptIgnore:
          begin
            LDecoder.Free;
            LDecoder := TIdMessageDecoderMIME.Create(nil);
          end;
        mcptEOF:
          begin
            LDecoder.Free;
            LMsgEnd := True;
          end;
      end;
    until (LDecoder = nil) or LMsgEnd;
  finally
    LDecoder.Free;
  end;
end;

begin
  Demo;
end.

【问题讨论】:

【参考方案1】:

在我的服务器应用程序中,我在分段上传中发现了关于 CRLF 的问题。我在方法“readbody”中看到解码器的 Headers.Values['Content-Transfer-Encoding'] 始终是空白和 Indy假设为默认 7 位(已编码)。

所以为了避免 CRLF 问题,我简单地设置了:

    TIdMessageDecoderMIME(decoder).Headers.Values['Content-Transfer-Encoding'] := '8bit';
    TIdMessageDecoderMIME(decoder).BodyEncoded := False;
    newdecoder := Decoder.ReadBody(ms,msgEnd);

【讨论】:

【参考方案2】:

当前的 SVN 版本是 5203,所以你在更新方面有点落后。

我使用 IE11 的 XE2 中的修订版 5203 按原样测试了您的代码。

我上传了一个测试.pas 文件,它在upload 文件夹中大了53 个字节。我可以确认解码前的原始PostStream 数据是正确的。

是的,我确实在文件末尾看到了一个额外的 CRLF,这与 TIdMessageDecoderMIME 解码非二进制非 base64/QP 编码数据的方式有关(您的示例没有)。它逐行读取数据,对每一行进行解码,在不使用 binary 传输编码时将解码后的行写入目标流并使用新的换行符。该逻辑没有考虑到 MIME 边界前面的换行符属于边界,而不是边界之前的数据。 MIME 规范对此非常明确,但 Indy 尚未将非 base64 数据考虑在内。

文件大小的其余差异都与将非 ASCII 字符转换为 $3F 字节序列有关,包括 UTF-8 BOM。这是因为PostStream 数据在TIdMessageDecoderMIME.ReadBody() 中被解码为 7 位 ASCII,因为没有与文件数据一起发送的 Content-Transfer-Encoding 标头,因此由于 RFC 2045 第 6.1 节中的此语句,Indy 默认为 ASCII:

如果 Content-Transfer-Encoding 标头字段不存在,则假定为“Content-Transfer-Encoding: 7BIT”。

但是,第 6.4 节陈述了以下内容,这似乎与 6.1 相矛盾:

任何具有无法识别的 Content-Transfer-Encoding 的实体都必须被视为其 Content-Type 为“application/octet-stream”,无论 Content-Type 标头字段实际表示什么。

ReadBody() 处理这两种情况,但是首先检查 6.1,因此 Indy 假定使用 7bit 编码,然后由于 7bit 不是无法识别的编码,因此它对 6.4 的处理无效。除非假设缺少的Content-Transfer-Encoding 应被视为无法识别的编码,而 Indy 目前没有。

上传的实际Content-Typeapplication/octet-stream,这意味着8位编码。当我在应用第 6.1 节时更新ReadBody() 以将application/octet-stream 视为8bit 而不是7bit 时,所有问题都消失了:

if LContentTransferEncoding = '' then begin
  // RLebeau 04/08/2014: According to RFC 2045 Section 6.1:
  // "Content-Transfer-Encoding: 7BIT" is assumed if the
  // Content-Transfer-Encoding header field is not present."
  if IsHeaderMediaType(LContentType, 'application/mac-binhex40') then begin  Do not Localize
    LContentTransferEncoding := 'binhex40'; do not localize
  end

  // START FIX!!
  else if IsHeaderMediaType(LContentType, 'application/octet-stream') then begin  Do not Localize
    LContentTransferEncoding := '8bit'; do not localize
  end
  // END FIX!!

  else begin
    LContentTransferEncoding := '7bit'; do not localize
  end;
end

上传的文件是正确的文件大小,字节被正确解码和写入,没有将非ASCII序列转换为$3F序列,文件末尾没有多余的CRLF。

我将不得不进一步调查这个问题,看看是否有更好的方法来处理这种差异。我已经在 Indy 的问题跟踪器中为它打开了票证。同时,如果您修补 Indy 副本,您有一个解决方法。

【讨论】:

感谢您提供令人难以置信的快速解决方案! (我的 DUnit 测试还没有涵盖这个多部分 HTTP 上传代码,所以这个变化被 QA 忽略了) 我已经检查了 Indy 的 SVN 的修复。

以上是关于多部分/表单数据请求的 Indy MIME 解码返回尾随 CR/LF的主要内容,如果未能解决你的问题,请参考以下文章

给定 MIME 消息的文本表示,我如何重新创建该消息?

发布多部分表单数据问题

使用 perl 在 MIME 中解码附件时出错

在 Python 3 中为 HTTP 请求创建带有二进制组件的多部分 MIME 消息

使用 python 解码 MIME 电子邮件

PHP多部分表单数据PUT请求?