开源一个封装WinINet而来便于使用的HttpAgnet(注意下文中所称的“HttpAgent”特指本文要讲述的开源库,并非对所有HTTP客户代理程序的泛称),顺便讨论几个WinINet和HTTP协议的问题。
源码下载地址:
百度网盘:https://pan.baidu.com/s/18hTxIh32sVFmOAe2I_1biQ
CSDN:https://download.csdn.net/download/passfuhao/10308031 (CSDN上传资源强制要求2积分,没分的去度盘下)
示例最简单的用法-访问URL
#include "HttpAgent.h"
using namespace std;
using namespace HttpAgent;
#if defined( _UNICODE )
typedef wstring String;
#else
typedef string String;
#endif
int main( int argc, char ** argv )
{
// 注意:
// CHttpAgent内部会使用IHTMLDocument2猜测text/html文档的编码
// 来解决乱码问题,所以需要用户调用CoInitialize[Ex]初始化COM环境。
if( FAILED( CoInitialize( NULL ) ) )
return GetLastError();
CHttpUrl Url( _T( "http://127.0.0.1/?Arg1=V1&参数2=值2" ) );
Url.Query[_T( "Arg3" )] = _T( "V3&=" );
CHttpAgent ag;
ag.SendRequest( Url );
// 若不在URL中附带查询参数,可这样写:
// ag.SendRequest( _T( "http://127.0.0.1" ) );
OutputDebugString( ag.StringOfRespond().data() );
return EXIT_SUCCESS;
}
以上代码发送请求报文:
GET /?Arg1=V1&%E5%8F%82%E6%95%B02=%E5%80%BC2&Arg3=V3%26%3D HTTP/1.1
Content-Type: application/x-www-form-urlencoded; Charset=utf-8
User-Agent: Mozilla/4.0 (compatible; MSIE 9.0; qdesk 2.4.1266.203; Windows NT 6.1; WOW64; Trident/7.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; GWX:RESERVED; InfoPath.3; .NET CLR 1.1.4322)
Host: 127.0.0.1
注意:查询串中被编码(RFC3986 第2.1节)的部分,使用了UTF8编码,如果希望使用GBK或其它编码,需调用CHttpUrl::SetCodePage设置。
提交application/x-www-form-urlencoded表单和使用IDN:
CForms fms;
fms[_T( "Arg1" )] = _T( "V1" );
fms[_T( "参数2" )] = _T( "值2" );
fms[_T( "Arg3" )] = _T( "V3&=" );
CHttpAgent ag;
ag.SendRequest( _T( "http://www.中文域名.com/" ), fms );
发送的报文:
POST http://www.xn--fiq06l2rdsvs.com/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded; Charset=utf-8
User-Agent: Mozilla/4.0 (compatible; MSIE 9.0; qdesk 2.4.1266.203; Windows NT 6.1; WOW64; Trident/7.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; GWX:RESERVED; InfoPath.3; .NET CLR 1.1.4322)
Host: www.xn--fiq06l2rdsvs.com
Content-Length: 52
Pragma: no-cache
Arg1=V1&%E5%8F%82%E6%95%B02=%E5%80%BC2&Arg3=V3%26%3D
注意:
提交multipart/form-data表单(包含上传文件、提交二进制数据):
CMultipartForms fms;
fms[_T( "Arg1" )] = _T( "V1" );
fms[_T( "参数2" )] = _T( "值2" );
fms[_T("Arg3")] = _T( "V3&=" );
BYTE Blob[] = "\x1\x2\x3\x4\x5\x6\x7\x8";
fms[_T( "Blob" )].SetBlob( Blob, sizeof( Blob ) );
fms[_T( "文件名" )].SetFile( _T( "c:\\1.txt" ) );
CHttpAgent ag;
ag.SendRequest( _T( "http://127.0.0.1" ), fms );
发送的报文(汉字是经过UTF8编码的):
注意:
完全控制请求报文:
LPCTSTR pszHeader =
_T( "Host: 127.0.0.1\r\n" )
_T( "Content-Length: 4\r\n" )
_T( "Referer: http://127.0.0.1/\r\n" )
_T("User-Agent: AbsolutePower\r\n")
_T( "Cookie: UserName=fuhao;Password=123456\r\n" );
CHttpAgent ag;
ag.SendRawRequest( _T( "http://127.0.0.1/" ),
_T( "POST" ), pszHeader, "1234", 4,
HttpIOInterface::RequestOptionNoCookies | // 不自动发送Cookie
HttpIOInterface::RequestOptionNoAutoRedirect | // 阻止自动重定向
HttpIOInterface::RequestOptionNoCacheWrite | // 不要缓存文件,也不要从缓存中读
HttpIOInterface::RequestOptionPragmaNoCache | // 不要从代理服务器读取缓存
HttpIOInterface::RequestOptionNoAuth ); // 不要试图自动认证,即使URL包含用户名和密码
发送的报文:
POST http://127.0.0.1/ HTTP/1.1
Host: 127.0.0.1
Content-Length: 4
Referer: http://127.0.0.1/
User-Agent: AbsolutePower
Cookie: UserName=fuhao;Password=123456
Pragma: no-cache
1234
注意:
InternetQueryOption和INTERNET_OPTION_SECURITY_CERTIFICATE_STRUCT标志得到证书并自行校验。当然,如果目的是忽略服务器证书错误,可以不做上述的最后一步,但是相应牺牲了安全保障。
使用HTTPS、HTTPS双向认证:
class CHttpAgentEx final : public CHttpAgent
{
protected:
virtual BOOL HttpIOEventProc( IHttpIOKernel *pIOKernel, HttpIOEvent EventId, LPVOID lpv )
{
if( EventId != HttpIOEvent::HttpIOEventNeedClientCert )
return FALSE;
HCERTSTORE hSto = CertOpenSystemStore( NULL, _T( "MY" ) );
if( hSto == NULL )
return FALSE;
/** 注意:
从 证书管理器(certmgr.msc)-> 证书 -> 详细信息 中看到的序列号和此处使用的序列号字节
序是相反的。因为证书文件以大端序存储序列号,证书管理器同样以大端序显示序列号(参图1),
而CRYPT_INTEGER_BLOB结构中存储的序列号则是小端序的。
所以,以下代码中使用的序列号和证书管理器上看到的序列号的字节序相反。(参图2)*/
BYTE szSerialNumber[] = { 0x9c,0xd0,0xb0,0x45,0xb7,0x32,0x00,0xe6,0xd1,0x80,0x0d,0x93,0x6f,0x40,0x70,0x42 };
// 通过序列号搜索证书
PCCERT_CONTEXT hCert = NULL;
CRYPT_INTEGER_BLOB SerialNumber = { sizeof( szSerialNumber ), szSerialNumber };
while( hCert = CertFindCertificateInStore( hSto,
X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, NULL, CERT_FIND_ANY, NULL, hCert ) ) {
if( CertCompareIntegerBlob(
&hCert->pCertInfo->SerialNumber, &SerialNumber ) )
break;
}
BOOL bSuccess = FALSE;
if( hCert != NULL ) {
bSuccess = InternetSetOption( (HINTERNET)lpv,
INTERNET_OPTION_CLIENT_CERT_CONTEXT, (LPVOID)hCert, sizeof( CERT_CONTEXT ) );
}
/** 注意:这是一段测试代码,此处有对象泄漏发生;
HttpIOEvent::HttpIOEventEnd 事件前不能执行以下用来销毁证书库和证书上下文对象的代码:
CertFreeCertificateContext( hCert );
CertCloseStore( hSto, CERT_CLOSE_STORE_FORCE_FLAG ); */
return bSuccess;
}
};
图1:
图2:
以下是 main 函数中的代码:
CHttpAgentEx ag;
ag.SendRequest( _T( "https://127.0.0.1/" ), _T( "GET" ),
NULL, NULL, 0, HttpIOInterface::RequestOptionIgnoreCaError );
如果一切顺利,服务器会给出一个非 403.7 状态的响应。当然,通常我们都会先遇到一些问题。以下罗列一些常见问题:
用户直接使用CHttpAgent发起需要 SSL/TLS 双向认证的请求或使用CHttpAgentEx处理HttpIOEvent::HttpIOEventNeedClientCert事件时返回FALSE会发生此情况。以下几种情况会导致CHttpAgentEx处理HttpIOEvent::HttpIOEventNeedClientCert事件时返回FALSE:
- 处理HttpIOEvent::HttpIOEventNeedClientCert事件时没有找到对应的客户证书。
- 处理HttpIOEvent::HttpIOEventNeedClientCert事件时没能将客户证书设置到请求对象上(InternetSetOption失败)。
使用CHttpAgentEx或其它方法处理HttpIOEvent::HttpIOEventNeedClientCert事件时在未(调用InternetSetOption)向请求对象设置客户证书或(调用InternetSetOption)向请求对象设置了错误的客户证书的情况下返回TRUE。
以上代码调用SendRequest函数使用了RequestOptionIgnoreCaError标志,该标志用来指示HttpAgent不要对服务端证书作合法性检查,HttpAgent会忽略以下几种服务端证书错误:
为了增强易用性,SendRequest函数会在默认情况下使用RequestOptionIgnoreCaError标志。一个已知的安全风险是 - HttpAgent会试图通过Internet Explorer API的Scripting Object Interfaces加载服务端流回的 text/html 类型文档来解析文档使用的字符集,若服务端流回的 text/html 文档中包含一些脚本甚至exploit程序,那么它将可能获得执行机会。如何在易用性和安全性之间折衷是门艺术,本文不作讨论。若希望HttpAgent在默认情况下对服务端证书作合法性校验只需去除SendRequest函数声名中dwOption参数的RequestOptionIgnoreCaError值即可。
更多有关如何在IIS中启用客户端证书身份验证和使用Crypt API查找证书的资料请参考 MSDN 或其它相关文档,以下列出一些你可能在意的链接:
Accept-Encoding 内容编码问题:
Windows Vista 之后的 WinINet 支持自动对响应内容作内容解码,只需调用InternetSetOption传入INTERNET_OPTION_HTTP_DECODING标志即可。但 Windows xp平台下 WinINet不支持内容解码。
我不太愿意在HttpAgent中引入zlib或其它能解码HTTP内容编码的第三方库来完成渐次解压,如果响应正文是经过压缩的且较大,势必需要边接收边解压。而且在通知用户(HttpIOEvent)时必须向用户传递已解压的数据。所以我放弃了在 xp 平台支持自动解压内容编码的功能,替代方案是在 xp 平台发起请求时强行替换(或增加)Accept-Encoding首部,将其值设为 identity; q=1.0, *;q=0 来告知服务器-客户端接受未经任何内容编码的正文。此时,若响应正文仍是经过内容编码的,HttpAgent将拒绝读取响应主体并产生一个ERROR_INTERNET_DECODING_FAILED错误。
这意味着只要在 xp 平台下HttpAgent发出的请求头中在默认情况下都会包含 Accept-Encoding 首部,除非客户在发起请求时使用RequestOptionDisableAutoUncompress标志,明确阻止HttpAgent内部自动作内容解码。那么,这引申出另一个问题-如何在请求头中保持Accept-Encoding首部的位置?答案也很简单-在请求头中包含Accept-Encoding首部并在 xp 平台下将其值设置为identity; q=1.0, *;q=0即可。
关于 Accept-Encoding: identity; q=1.0, *;q=0 这个充满玄机的写法是综合考量以下问题后得出的结论。
综上, Accept-Encoding: identity; q=1.0, *;q=0 的完整语义为:设置identity为权重最大的内容编码方案并设置其它内容编码方案的权重为0(既'not acceptable',参考RFC2616 3.9节),若服务器无法满足该语义应当回应406状态(参考 RFC2616 14.3节)。当然,这不能排除一些行为非凡或HTTP/1.0(或声称兼容HTTP/1.1)的服务器仍然回应一个经过内容编码的主体,如果HttpAgent遇到此情况将会拒绝读取响应主体并返回一个错误。
这是HttpAgent在不同平台表现不一致之处,值得特别留意。
Content-Type、Charset相关的内容协商问题:
和HTTP协议相关的一个神奇的问题是,无论怎么处理字符编码都有人说不对,即便Chrome、IE这样的主流浏览器也无法完全避免乱码问题。虽然这大都是HTTP/1.0的锅-HTTP/1.1为了更好的向前兼容明确了“接收者应当在响应首部Content-Type缺少Charset域时猜测正文的字符集”,但却为很多粗心的服务程序开发者找到了很好的借口:“你看-浏览器里就不是乱码,即便没为Content-Type设置Charset域”,而我通常会让TA试试其它语言版的IE。
RFC2616 3.4.1中虽然明确了“接收者应该猜测”的规定但未界定“猜测边界”。那么何时才应停止猜测转而使用 iso-8859-1 作为默认字符集?浏览器对“猜测”的实现通常是在有限集中查找可能性最大的字符集作为结果,甚至查找过程内部本身也使用某种特定的字符集作为猜测正确率较低时的默认结果(IE就是个典范,它会在很多时候使用和当前线程语言对应的字符集),这就导致一旦“猜测”发生,永远都不会使用RFC规定的“默认字符集”作为结果,除非猜测结果碰巧和RFC规定的默认字符集相同。如此,不包含Charset域的Content-Type首部的语义被顺理成章的解释为“接收者可以认定它是任意字符集”而引起歧义。
当然,对于上述问题HttpAgent也无能为力,但值得深思的是,面向非B/S架构开发者的HttpAgent是否需要具备像IE甚至Chrome那样的字符集识别能力?我得到了否定的答案-C/S架构软件通常需要处理严谨、确切的逻辑关系(例如:if( RespondBody=="中文" )这样的语句),那么,猜测字符集变的没有意义了。此外,猜测字符集可能会带来不少工作量。浏览器具备此能力的原因之一是即便猜错字符集也仅影响了用户阅读(会在确认即便猜错也不会引起副作用或只会引起可接受的副作用的提前下进行猜测),因为它们可以得知何时可以相信猜测结果。另外则是从软件的架构层次考虑,这个用来收发HTTP流的HttpAgent是否需要具备具备强大的字符集识别能力?同样得到了否定的答案-HttpAgent所处的层面决定了它应当具备的主要功能是接收和发送HTTP流或对HTTP流作一些简单处理,无须为HttpAgent设计字符集识别能力。而且HttpAgent所处的层面也无法评估猜测错误会带来多大风险。
我更倾向于通过严格的规范来达成协商一致的目的,而不是任何单方的猜测。事实也可能不允许猜测的发生,所以仅为HttpAgent设计了包括通过 标签猜测text/html类型主体字符集和少许其它猜测方法。以下详细解释 HttpAgent如何处理text/html类型主体:
如果在使用HttpAgent过程中仍发生了乱码现象,多半是你的错。
当你发现了编译器的BUG时,要小心了,因为编译器可能比你掌握了更多知识 ——《C++ Primer》。
字符编码转换问题:
用户在发送 HTTP 请求时可能希望使用各种和当前系统不同的字符集,因此,需要处理各种不同字符集间的相互转换。看起来只要调用WideCharToMultiByte和MultiByteToWideChar就能完成字符集间的转换工作,但事实是,这两个函数并非总能完成转换任务。若当前系统没有安装用户要求的转向的字符集的NLS 文件,也没有提供转换字符集时可用的导出了NlsDllCodePageTranslation函数的 DLL文件,那么,转换一定会宣告失败,对于转换失败的处理是—— 弹出一个错误消息框提示代码页转换失败然后调用FatalAppExit退出进程。至于如何保证总是转换成功,建议使用CP_UTF8 作为目的代码页。
另一个字符编码转换问题是很多代码页的默认字符是'?' 那么,如果 '?' 在某个字符串中有特殊涵义则可能导致逻辑错误。例如“http://www.example.com/Xabc=123/path/”串,若使用该串调用WideCharToMultiByte(假设因符号 'X' 不可向设定的代码页转换,而使用默认字符,则很可能得到“http://www.example.com/?abc=123/path/”。转换后导致 URL 语义更改,用于表示资源地址的符号被转换为用于分隔查询字符串的'?' ,导致逻辑错误。所以,HttpAgent会检查转换过程中是否因有符号无法转向指定代码页而使用了默认字符,如果是,则视为转换失败。
其它:
为了便于调试,HttpAgnet默认使用IE 的代理设置,设置ProxyModeDefault的值为1可将默认的代理方式改为“直连”,或许可以阻止一些只会通过工具篡改报文的人。
HttpAgent不依赖除Windows API 和 c++标准库外的任何第三方库。支持IIS所支持的所有认证方式的自动认证以及SSL/TLS双向认证,还支持传输编码自动解码和无限长URL、国际化域名等功能。
为了让使用者编写更少的代码,更容易正确使用,HttpAgent抛弃了KeepAlived语义且非异步实现。
HttpAgnet所有函数都符合最高异常安全等级。由于C++11的容器针对异常安全作了大量优化,使用高版本的VS(建议使用VS2017)编译可带来更高效率。
HttpAgent不适合用于下载文件,也不支持Trailer,在XP平台也不支持对内容编码自动解码。
术语对照表:
本文使用的名词 | RFC中使用的名词 |
首部 | field |
首部的值 | field-value |
qvalue | 'Quality Values' or 'qvalue' |
权重 | weight |
相关参考:
内容编码、qvalue、编码格式、WinINet自动解码:
RFC2616:https://tools.ietf.org/html/rfc2616 第 14.3节、3.5节、3.9节
MSDN 对INTERNET_OPTION_HTTP_DECODING 的解释:https://msdn.microsoft.com/en-us/library/aa383955
HTTP认证、代理认证、SSL/TLS双向认证:
处理HTTP认证:https://msdn.microsoft.com/en-us/library/windows/desktop/aa384220(v=vs.85).aspx
使用WinINet进行代理认证:https://msdn.microsoft.com/en-us/library/aa383996
如何启用IIS双向认证:https://blogs.msdn.microsoft.com/bradleycotier/2011/12/14/mutual-authentication-with-a-iis-hosted-wcf-data-service-installed-in-a-workgroup-environment/
如何查找证书并得到CCERT_CONTEXT对象:https://msdn.microsoft.com/en-us/library/windows/desktop/aa382039(v=vs.85).aspx
如何使用INTERNET_OPTION_CLIENT_CERT_CONTEXT选项在WinINet请求对象上设置客户证书:https://msdn.microsoft.com/en-us/library/aa385328
内容协商、Media Type、字符集、默认字符集、Charset的可选值:
RFC2616:https://tools.ietf.org/html/rfc2616 第 3.1节、3.4节、3.7节
注册到IANA的Charset(Charset域可选值):https://www.iana.org/assignments/character-sets/character-sets.xhtml
Windows代码页:
Code Page Identifiers:https://msdn.microsoft.com/en-us/library/windows/desktop/dd317756(v=vs.85).aspx
NlsDllCodePageTranslation:https://msdn.microsoft.com/en-us/library/windows/desktop/dd319085(v=vs.85).aspx
如何设置自动代理:
IE8:https://technet.microsoft.com/zh-cn/library/cc985352.aspx
IE11:https://technet.microsoft.com/zh-cn/library/dn321458.aspx
示例:https://technet.microsoft.com/zh-cn/library/cc985335.aspx
如何加载流到IHTMLDocument对象:
使用 IPersistStreamInit::Load:https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa752047(v=vs.85)
使用IMarkupContainer::ParseGlobal:https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa769265(v%3dvs.85)
最后,关于如何调试32位和64位的DLL:
找到 HttpIOKernel的项目属性页 -> 配置属性 -> 调试,将右侧的命令参数设置为:$(TargetPath),Rundll32EntryPoint,命令按以下条件设置(假设Windows被安装在C盘):
如果DLL运行在64位平台而且当前系统是64位:C:\Windows\System32\Rundll32.exe
如果DLL运行在64位平台但当前系统是32位:无法调试
如果DLL运行在32位平台且当前系统是32位:C:\Windows\System32\Rundll32.exe
如果DLL运行在32位平台但当前系统是64位:C:\Windows\SysWOW64\Rundll32.exe
关于Rundll32EntryPoint请参考:https://support.microsoft.com/zh-cn/help/164787/info-windows-rundll-and-rundll32-interface