Bug 1461690 Part 3: HttpPostFile plugin for uploading uninstall ping via POST. r=mhowell

Differential Revision: https://phabricator.services.mozilla.com/D93717
This commit is contained in:
Adam Gashlin 2020-10-20 23:20:25 +00:00
Родитель d8b64a9146
Коммит 23d03c7659
6 изменённых файлов: 699 добавлений и 0 удалений

Просмотреть файл

@ -0,0 +1,304 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// To explain some of the oddities:
// This plugin avoids linking against a runtime that might not be present, thus
// it avoids standard library functions.
// NSIS requires GlobalAlloc/GlobalFree for its interfaces, and I use them for
// other allocations (vs e.g. HeapAlloc) for the sake of consistency.
#include <Windows.h>
#include <Wininet.h>
#define AGENT_NAME L"HttpPostFile plugin"
PBYTE LoadFileData(LPWSTR fileName, DWORD& cbData);
bool HttpPost(LPURL_COMPONENTS pUrl, LPWSTR contentTypeHeader, PBYTE data,
DWORD cbData);
// NSIS API
typedef struct _stack_t {
struct _stack_t* next;
WCHAR text[1];
} stack_t;
// Unlink and return the topmost element of the stack, if any.
static stack_t* popstack(stack_t** stacktop) {
if (!stacktop || !*stacktop) return nullptr;
stack_t* element = *stacktop;
*stacktop = element->next;
element->next = nullptr;
return element;
}
// Allocate a new stack element (with space for `stringsize`), copy the string,
// add to the top of the stack.
static void pushstring(LPCWSTR str, stack_t** stacktop,
unsigned int stringsize) {
stack_t* element;
if (!stacktop) return;
// The allocation here has space for stringsize+1 WCHARs, because stack_t.text
// is 1 element long. This is consistent with the NSIS ExDLL example, though
// inconsistent with the comment that says the array "should be the length of
// g_stringsize when allocating". I'm sticking to consistency with
// the code, and erring towards having a larger buffer than necessary.
element = (stack_t*)GlobalAlloc(
GPTR, (sizeof(stack_t) + stringsize * sizeof(*str)));
lstrcpynW(element->text, str, stringsize);
element->next = *stacktop;
*stacktop = element;
}
BOOL APIENTRY DllMain(HINSTANCE instance, DWORD reason, LPVOID) {
// No initialization or cleanup is needed.
return TRUE;
}
extern "C" {
// HttpPostFile::Post <File> <Content-Type header with \r\n> <URL>
//
// e.g. HttpPostFile "C:\blah.json" "Content-Type: application/json$\r$\n"
// "https://example.com"
//
// Leaves a result string on the stack, "success" if the POST was successful, an
// error message otherwise.
// The status code from the server is not checked, as long as we got some
// response the result will be "success". The response is read, but discarded.
void __declspec(dllexport)
Post(HWND hwndParent, int string_size, char* /* variables */,
stack_t** stacktop, void* /* extra_parameters */) {
static const URL_COMPONENTS kZeroComponents = {0};
const WCHAR* errorMsg = L"error";
DWORD cbData = INVALID_FILE_SIZE;
PBYTE data = nullptr;
// Copy a constant, because initializing an automatic variable with {0} ends
// up linking to memset, which isn't available.
URL_COMPONENTS components = kZeroComponents;
// Get args, taking ownership of the strings from the stack, to avoid
// allocating and copying strings.
stack_t* postFileName = popstack(stacktop);
stack_t* contentTypeHeader = popstack(stacktop);
stack_t* url = popstack(stacktop);
if (!postFileName || !contentTypeHeader || !url) {
errorMsg = L"error getting arguments";
goto finish;
}
data = LoadFileData(postFileName->text, cbData);
if (!data || cbData == INVALID_FILE_SIZE) {
errorMsg = L"error reading file";
goto finish;
}
{
// This length is used to allocate for the host name and path components,
// which should be no longer than the source URL.
int urlBufLen = lstrlenW(url->text) + 1;
components.dwStructSize = sizeof(components);
components.dwHostNameLength = urlBufLen;
components.dwUrlPathLength = urlBufLen;
components.lpszHostName =
(LPWSTR)GlobalAlloc(GPTR, urlBufLen * sizeof(WCHAR));
components.lpszUrlPath =
(LPWSTR)GlobalAlloc(GPTR, urlBufLen * sizeof(WCHAR));
}
errorMsg = L"error parsing URL";
if (components.lpszHostName && components.lpszUrlPath &&
InternetCrackUrl(url->text, 0, 0, &components) &&
(components.nScheme == INTERNET_SCHEME_HTTP ||
components.nScheme == INTERNET_SCHEME_HTTPS)) {
errorMsg = L"error sending HTTP request";
if (HttpPost(&components, contentTypeHeader->text, data, cbData)) {
// success!
errorMsg = nullptr;
}
}
finish:
if (components.lpszUrlPath) {
GlobalFree(components.lpszUrlPath);
}
if (components.lpszHostName) {
GlobalFree(components.lpszHostName);
}
if (data) {
GlobalFree(data);
}
// Free args taken from the NSIS stack
if (url) {
GlobalFree(url);
}
if (contentTypeHeader) {
GlobalFree(contentTypeHeader);
}
if (postFileName) {
GlobalFree(postFileName);
}
if (errorMsg) {
pushstring(errorMsg, stacktop, string_size);
} else {
pushstring(L"success", stacktop, string_size);
}
}
}
// Returns buffer with file contents on success, placing the size in cbData.
// Returns nullptr on failure.
// Caller must use GlobalFree() on the returned buffer if non-null.
PBYTE LoadFileData(LPWSTR fileName, DWORD& cbData) {
bool success = false;
HANDLE hPostFile = INVALID_HANDLE_VALUE;
PBYTE data = nullptr;
DWORD bytesRead;
DWORD bytesReadTotal;
hPostFile = CreateFile(fileName, GENERIC_READ, FILE_SHARE_READ, nullptr,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (hPostFile == INVALID_HANDLE_VALUE) {
goto finish;
}
cbData = GetFileSize(hPostFile, NULL);
if (cbData == INVALID_FILE_SIZE) {
goto finish;
}
data = (PBYTE)GlobalAlloc(GPTR, cbData);
if (!data) {
goto finish;
}
bytesReadTotal = 0;
do {
if (!ReadFile(hPostFile, data + bytesReadTotal, cbData - bytesReadTotal,
&bytesRead, nullptr /* overlapped */)) {
goto finish;
}
bytesReadTotal += bytesRead;
} while (bytesReadTotal < cbData && bytesRead > 0);
if (bytesReadTotal == cbData) {
success = true;
}
finish:
if (!success) {
if (data) {
GlobalFree(data);
data = nullptr;
}
cbData = INVALID_FILE_SIZE;
}
if (hPostFile != INVALID_HANDLE_VALUE) {
CloseHandle(hPostFile);
hPostFile = INVALID_HANDLE_VALUE;
}
return data;
}
// Returns true on success
bool HttpPost(LPURL_COMPONENTS pUrl, LPWSTR contentTypeHeader, PBYTE data,
DWORD cbData) {
bool success = false;
HINTERNET hInternet = nullptr;
HINTERNET hConnect = nullptr;
HINTERNET hRequest = nullptr;
hInternet = InternetOpen(AGENT_NAME, INTERNET_OPEN_TYPE_PRECONFIG,
nullptr, // proxy
nullptr, // proxy bypass
0 // flags
);
if (!hInternet) {
goto finish;
}
hConnect = InternetConnect(hInternet, pUrl->lpszHostName, pUrl->nPort,
nullptr, // userName,
nullptr, // password
INTERNET_SERVICE_HTTP,
0, // flags
0 // context
);
if (!hConnect) {
goto finish;
}
{
// NOTE: Some of these settings are perhaps unnecessary for a POST.
DWORD httpFlags = INTERNET_FLAG_NO_CACHE_WRITE | INTERNET_FLAG_NO_COOKIES |
INTERNET_FLAG_NO_UI | INTERNET_FLAG_RELOAD;
if (pUrl->nScheme == INTERNET_SCHEME_HTTPS) {
// NOTE: nsJSON sets flags to allow redirecting HTTPS to HTTP, or HTTP to
// HTTPS I left those out because it seemed undesirable for our use case.
httpFlags |= INTERNET_FLAG_SECURE;
}
hRequest = HttpOpenRequest(hConnect, L"POST", pUrl->lpszUrlPath,
nullptr, // version,
nullptr, // referrer
nullptr, // accept types
httpFlags,
0 // context
);
if (!hRequest) {
goto finish;
}
}
if (contentTypeHeader) {
if (!HttpAddRequestHeaders(hRequest, contentTypeHeader,
-1L, // headers length (count string length)
HTTP_ADDREQ_FLAG_ADD)) {
goto finish;
}
}
if (!HttpSendRequestW(hRequest,
nullptr, // additional headers
0, // headers length
data, cbData)) {
goto finish;
}
BYTE readBuffer[1024];
DWORD bytesRead;
do {
if (!InternetReadFile(hRequest, readBuffer, sizeof(readBuffer),
&bytesRead)) {
goto finish;
}
// read data is thrown away
} while (bytesRead > 0);
success = true;
finish:
if (hRequest) {
InternetCloseHandle(hRequest);
}
if (hConnect) {
InternetCloseHandle(hConnect);
}
if (hInternet) {
InternetCloseHandle(hInternet);
}
return success;
}

Просмотреть файл

@ -0,0 +1,22 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.30128.74
MinimumVisualStudioVersion = 10.0.40219.1
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "HttpPostFile", "HttpPostFile.vcxproj", "{A8BF99FD-8603-4137-862A-1D14268D7812}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A8BF99FD-8603-4137-862A-1D14268D7812}.Release|x86.ActiveCfg = Release|Win32
{A8BF99FD-8603-4137-862A-1D14268D7812}.Release|x86.Build.0 = Release|Win32
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {5EF33D14-5BB9-4E44-A347-9FF33E86D9DC}
EndGlobalSection
EndGlobal

Просмотреть файл

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Release|Win32">
<Configuration>Release</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>16.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{a8bf99fd-8603-4137-862a-1d14268d7812}</ProjectGuid>
<RootNamespace>HttpPostFile</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v142</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<LinkIncremental>false</LinkIncremental>
</PropertyGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<PreprocessorDefinitions>WINVER=0x601;_WIN32_WINNT=0x601;WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<OmitDefaultLibName>true</OmitDefaultLibName>
<ExceptionHandling>false</ExceptionHandling>
<SDLCheck>false</SDLCheck>
<BufferSecurityCheck>false</BufferSecurityCheck>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
<AdditionalDependencies>wininet.lib;%(AdditionalDependencies)</AdditionalDependencies>
<EntryPointSymbol>DllMain</EntryPointSymbol>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="HttpPostFile.cpp" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
</ImportGroup>
</Project>

Просмотреть файл

@ -0,0 +1,63 @@
; Any copyright is dedicated to the Public Domain.
; http://creativecommons.org/publicdomain/zero/1.0/
; Simple driver for HttpPostFile, passes command line args to HttpPostFile::Post and
; writes the result string to a file for automated checking.
; Always specifies Content-Type: application/json
;
; Usage: posttest /postfile=postfile.json /url=http://example.com /resultfile=result.txt
!include LogicLib.nsh
!include FileFunc.nsh
OutFile "postdriver.exe"
RequestExecutionLevel user
ShowInstDetails show
Unicode true
!addplugindir ..\..\..\Plugins
Var PostFileArg
Var UrlArg
Var ResultFileArg
Var ResultString
Section
StrCpy $ResultString "error getting command line arguments"
ClearErrors
${GetParameters} $0
IfErrors done
ClearErrors
${GetOptions} " $0" " /postfile=" $PostFileArg
IfErrors done
${GetOptions} " $0" " /url=" $UrlArg
IfErrors done
${GetOptions} " $0" " /resultfile=" $ResultFileArg
IfErrors done
DetailPrint "POST File = $PostFileArg"
DetailPrint "URL = $UrlArg"
DetailPrint "Result File = $ResultFileArg"
StrCpy $ResultString "error running plugin"
HttpPostFile::Post $PostFileArg "Content-Type: application/json$\r$\n" $UrlArg
Pop $ResultString
done:
${If} $ResultString != "success"
DetailPrint $ResultString
${EndIf}
ClearErrors
FileOpen $0 $ResultFileArg "w"
${Unless} ${Errors}
FileWrite $0 $ResultString
FileClose $0
${EndUnless}
SectionEnd

Просмотреть файл

@ -0,0 +1,247 @@
# Any copyright is dedicated to the Public Domain.
# http://creativecommons.org/publicdomain/zero/1.0/
# Unit test for the HttpPostFile plugin, using a server on localhost.
#
# This test has not been set up to run in continuous integration. It is
# intended to be run manually, and only on Windows.
#
# Requires postdriver.exe, which can be built from postdriver.nsi with makensis
# from MozillaBuild:
#
# makensis-3.01.exe postdriver.nsi
#
# It can then be run from this directory as:
#
# python3 test.py
import os
import subprocess
import http.server
import socketserver
import threading
DRIVER_EXE_FILE_NAME = "postdriver.exe"
JSON_FILE_NAME = "test1.json"
RESULT_FILE_NAME = "result.txt"
BIND_HOST = "127.0.0.1"
BIND_PORT = 8080
COMMON_URL = f"http://{BIND_HOST}:{BIND_PORT}/submit"
COMMON_JSON_BYTES = '{"yes": "indeed",\n"and": "ij"}'.encode('utf-8')
DRIVER_TIMEOUT_SECS = 60
SERVER_TIMEOUT_SECS = 120
class PostHandler(http.server.BaseHTTPRequestHandler):
"""BaseHTTPRequestHandler, basically just here to have a configurable do_POST handler"""
last_submission = None
last_content_type = None
server_response = 'Hello, plugin'.encode('utf-8')
def server_accept_submit(handler):
"""Plugs into PostHandler.do_POST, accepts a POST on /submit and saves it into
the globals"""
global last_submission
global last_content_type
global server_response
last_submission = None
last_content_type = None
if handler.path == "/submit":
handler.send_response(200, 'Ok')
content_length = int(handler.headers['Content-Length'])
last_submission = handler.rfile.read(content_length)
last_content_type = handler.headers['Content-Type']
else:
handler.send_response(404, 'Not found')
handler.end_headers()
handler.wfile.write(server_response)
handler.wfile.flush()
handler.log_message("sent response")
server_hang_event = None
def server_hang(handler):
"""Plugs into PostHandler.do_POST, waits on server_hang_event or until timeout"""
server_hang_event.wait(SERVER_TIMEOUT_SECS)
def run_and_assert_result(handle_request, post_file, url, expected_result):
"""Sets up the server on another thread, runs the NSIS driver, and checks the result"""
global last_submission
global server_hang_event
try:
os.remove(RESULT_FILE_NAME)
except FileNotFoundError:
pass
PostHandler.do_POST = handle_request
last_submission = None
def handler_thread():
with socketserver.TCPServer((BIND_HOST, BIND_PORT), PostHandler) as httpd:
httpd.timeout = SERVER_TIMEOUT_SECS
httpd.handle_request()
if handle_request:
server_thread = threading.Thread(target=handler_thread)
server_thread.start()
try:
subprocess.call([DRIVER_EXE_FILE_NAME, f'/postfile={post_file}', f'/url={url}',
f'/resultfile={RESULT_FILE_NAME}', '/S'], timeout=DRIVER_TIMEOUT_SECS)
with open(RESULT_FILE_NAME, "r") as result_file:
result = result_file.read()
if result != expected_result:
raise AssertionError(f'{result} != {expected_result}')
finally:
if server_hang_event:
server_hang_event.set()
if handle_request:
server_thread.join()
os.remove(RESULT_FILE_NAME)
def create_json_file(json_bytes=COMMON_JSON_BYTES):
with open(JSON_FILE_NAME, "wb") as outfile:
outfile.write(json_bytes)
def check_submission(json_bytes=COMMON_JSON_BYTES):
if last_submission != json_bytes:
raise AssertionError(f'{last_submission.hex()} != {COMMON_JSON_BYTES}')
def cleanup_json_file():
os.remove(JSON_FILE_NAME)
# Tests begin here
try:
cleanup_json_file()
except FileNotFoundError:
pass
# Basic test
create_json_file()
run_and_assert_result(server_accept_submit, JSON_FILE_NAME, COMMON_URL, "success")
check_submission()
assert last_content_type == 'application/json'
cleanup_json_file()
print("Basic test OK\n")
# Test with missing file
try:
cleanup_json_file()
except FileNotFoundError:
pass
run_and_assert_result(None, JSON_FILE_NAME, COMMON_URL, "error reading file")
print("Missing file test OK\n")
# Test with empty file
create_json_file(bytes())
run_and_assert_result(server_accept_submit, JSON_FILE_NAME, COMMON_URL, "success")
check_submission(bytes())
cleanup_json_file()
print("Empty file test OK\n")
# Test with large file
# NOTE: Not actually JSON, but nothing here should care
four_mbytes = bytes([x & 255 for x in range(4*1024*1024)])
create_json_file(four_mbytes)
run_and_assert_result(server_accept_submit, JSON_FILE_NAME, COMMON_URL, "success")
if last_submission != four_mbytes:
raise AssertionError("large file mismatch")
cleanup_json_file()
print("Large file test OK\n")
# Test with long file name
# Test with bad URL
bogus_url = "notAUrl"
create_json_file()
run_and_assert_result(None, JSON_FILE_NAME, bogus_url, "error parsing URL")
cleanup_json_file()
print("Bad URL test OK\n")
# Test with empty response
server_response = bytes()
create_json_file()
run_and_assert_result(server_accept_submit, JSON_FILE_NAME, COMMON_URL, "success")
check_submission()
cleanup_json_file()
print("Empty response test OK\n")
# Test with large response
server_response = four_mbytes
create_json_file()
run_and_assert_result(server_accept_submit, JSON_FILE_NAME, COMMON_URL, "success")
check_submission()
cleanup_json_file()
print("Large response test OK\n")
# Test with 404
# NOTE: This succeeds since the client doesn't check the status code
create_json_file()
nonexistent_url = f"http://{BIND_HOST}:{BIND_PORT}/bad"
run_and_assert_result(server_accept_submit, JSON_FILE_NAME, nonexistent_url, "success")
cleanup_json_file()
print("404 response test OK\n")
# Test with no server on the port
# NOTE: I'm assuming nothing else has been able to bind to the port
print("Running no server test, this will take a few seconds...")
create_json_file()
run_and_assert_result(None, JSON_FILE_NAME, COMMON_URL, "error sending HTTP request")
cleanup_json_file()
print("No server test OK\n")
# Test with server that hangs on response
# NOTE: HttpPostFile doesn't currently set the timeouts. Defaults seem to be around 30 seconds,
# but if they end up being longer than the 60 second driver timeout then this will fail.
print("Running server hang test, this will take up to a minute...")
server_hang_event = threading.Event()
create_json_file()
run_and_assert_result(server_hang, JSON_FILE_NAME, COMMON_URL, "error sending HTTP request")
cleanup_json_file()
server_hang_event = None
print("Server hang test OK\n")

Двоичные данные
other-licenses/nsis/Plugins/HttpPostFile.dll Normal file

Двоичный файл не отображается.