I'm honestly not sure whether this is a PowerShell/.NET bug or a bug on the server side because I don't know enough about the underlying specs to say who is right. Essentially, I'm trying to use the new multipart support in Invoke-RestMethod to upload a file using the PaloAlto 8.0 XML API. Using curl, the upload works as expected. Using IRM, it returns an HTTP 400 response with the message, "Bad upload state. Incomplete boundary."
I have Fiddler captures of both requests and the only significant difference I can find is that the boundary value in the Content-Type header is surrounded by quotes when using IRM and not when using curl. For example:
# curl
Content-Type: multipart/form-data; boundary=------------------------05c3b7e48217500c
# Invoke-RestMethod
Content-Type: multipart/form-data; boundary="a8174dc8-8c8b-4090-a7fb-678422e73e79"
If I use Fiddler to edit the IRM request, remove the quotes, and replay it, the request succeeds which leads me to believe the PAN API interpreter doesn't like the quotes. Whether the quotes are allowed by the relevant specs or not, I have no clue.
# in this example, the file upload is PEM formatted x509 certificate file
$form = @{file=(Get-ChildItem C:\cert.pem)}
$pankey = '<my auth key>'
$querystring = "type=import&category=certificate&certificate-name=test&format=pem&key=$pankey"
# (the Accept header is just to mimic the curl command more closely)
Invoke-RestMethod "https://pan.example.com/api/?$querystring" -Method Post -Form $form -Headers @{Accept='*/*'}
An HTTP 200 response with a parsed version of the following XML body.
<response status="success"><result>Successfully imported test into candidate configuration</result></response>
An HTTP 400 response with the following body.
<!DOCTYPE html>
<html><head><title>Document Error: Bad Request</title></head>
<body><h2>Access Error: 400 -- Bad Request</h2>
<p>Bad upload state. Incomplete boundary
</p>
</body>
</html>
I'm currently testing this on Windows Server 2019 Standard (1809) with both the released PowerShell 6.1.3 package and the preview 6.2.0 RC.1 package.
PS C:\> $PSVersionTable
Name Value
---- -----
PSVersion 6.1.3
PSEdition Core
GitCommitId 6.1.3
OS Microsoft Windows 10.0.17763
Platform Win32NT
PSCompatibleVersions {1.0, 2.0, 3.0, 4.0...}
PSRemotingProtocolVersion 2.3
SerializationVersion 1.1.0.1
WSManStackVersion 3.0
PS C:\> $PSVersionTable
Name Value
---- -----
PSVersion 6.2.0-rc.1
PSEdition Core
GitCommitId 6.2.0-rc.1
OS Microsoft Windows 10.0.17763
Platform Win32NT
PSCompatibleVersions {1.0, 2.0, 3.0, 4.0鈥
PSRemotingProtocolVersion 2.3
SerializationVersion 1.1.0.1
WSManStackVersion 3.0
Edit: Forgot to tag @markekraus
cc @markekraus
As I suspected, we are dealing with API endpoints which are not RFC compliant:
https://tools.ietf.org/html/rfc7578#section-4.1
it is often necessary to enclose the "boundary" parameter values in quotes in the Content-Type header field.
APIs _should_ implement support for quoted boundary parameter values. I would suggest this is actually a bug in the API and neither .NET Core or PowerShell.
That being said, this is not the first time this has been reported to me personally. Additionally, we already have modified behavior from .NET Core in filenames #6780.
https://stackoverflow.com/questions/30926645/httpcontent-boundary-double-quotes Provides a possible workaround
Unfortunately, changing this may result in breaking changes for other endpoints. *sigh.
I had a feeling this might be the case. Gotta love poor API implementations. Thanks for the potential workaround though (and all the previous improvements to the web cmdlets). For my particular use-case, I can probably make that work.
In the general case, I can definitely see how it's a damned if you do, damned if you don't situation.
@markekraus would it be acceptable to retry without the quotes if you get a 400 error?
It's definitely more work than just using -Form, but here's a function to generate a MultiPartFormDataContent object with the workaround that seems to be working for me:
function New-MultipartFileContent {
[OutputType('System.Net.Http.MultipartFormDataContent')]
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[System.IO.FileInfo]$File,
[string]$HeaderName='file'
)
# build the header and make sure to include quotes around Name
# and FileName like https://github.com/PowerShell/PowerShell/pull/6782)
$fileHeader = [System.Net.Http.Headers.ContentDispositionHeaderValue]::new('form-data')
$fileHeader.Name = "`"$HeaderName`""
$fileHeader.FileName = "`"$($File.Name)`""
# build the content
$fs = [System.IO.FileStream]::new($File.FullName, [System.IO.FileMode]::Open)
$fileContent = [System.Net.Http.StreamContent]::new($fs)
$fileContent.Headers.ContentDisposition = $fileHeader
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/octet-stream')
# add it to a new MultipartFormDataContent object
$mp = [System.Net.Http.MultipartFormDataContent]::new()
$mp.Add($fileContent)
# get rid of the quotes around the boundary value
# https://github.com/PowerShell/PowerShell/issues/9241
$b = $mp.Headers.ContentType.Parameters | Where-Object { $_.Name -eq 'boundary' }
$b.Value = $b.Value.Trim('"')
# return an array wrapped copy of the object to avoid PowerShell unrolling
return @(,$mp)
}
Using this function's return value with the -Body parameter gets successfully processed by the PAN API now. Though it has the potential to leave the file handles open if you don't actually send the output to Invoke-RestMethod. For smaller files, you can use ReadAllBytes and ByteArrayContent rather than StreamContent to avoid leaving file handles open.
@SteveL-MSFT maybe retrying is ok. I'm a bit cautious of retries with Multipart forms. If they contain several large files, we could be duplicating the payload for an issue that may not be related to a quoted boundary parameter.
@markekraus my reading of that sentence in the RFC "...often necessary..." doesn't mean mandatory. I wonder if curl always leaves off the quotes in which case it might be best to align with their default.
Right. Supplying it is not mandatory. But since it is a possible request, that means an API _should_ implement it. One that doesn't is non-compliant. But the opposite may be true to. I'm just pointing out that if we change the behavior we may end up causing APIs that mistakenly require it (which are also non-compliant) to suddenly break for users. Just laying out the risks of the change. No clue how likely that risk is.
My general position is that without any data, following curl's lead is more likely to be right than wrong.
Most helpful comment
My general position is that without any data, following curl's lead is more likely to be right than wrong.