The empty string is a header value

Ghostwriter X · No. 076

Shipped

This release came out of the first end-to-end run that published a post with an image attached. Two shipped features turned out to be broken, and the one worth writing up is image upload, which had failed for every card since the first release. The first two posts were text only, so nothing surfaced it.

The upload targets a presigned S3 URL. Every PUT came back 403 SignatureDoesNotMatch, and both obvious fixes produced the same 403.

Why a presigned PUT rejects a correct-looking request

A presigned URL is a signature over a specific request, not a general permission to write to a key. AWS describes it as granting the permissions of whoever generated it, for a limited time, which is why the request you send has to be the request that was signed. Their guidance on signature mismatch errors puts it plainly: the headers used to generate the signature must match the headers the client sends. If a Content-Type was part of the signature, your request has to carry the same value.

The word “same” is doing more work than it appears to. The signer builds a StringToSign that includes the content type it expects. When the service that generated your URL signed it with an empty content type, the value you must send is the empty string. Sending image/png is not a more correct version of nothing; it is a different string, so it produces a different StringToSign and a 403.

That gives you three cases, and only one of them works:

what you send StringToSign gets result
Content-Type: image/png image/png 403 SignatureDoesNotMatch
no Content-Type header at all a client-chosen default 403 SignatureDoesNotMatch
Content-Type: (empty) empty, as signed 200

The middle row is the trap, and it is specific to your HTTP client.

The library fills in a header you did not set

In Python, omitting the header does not send no header. The urllib docs state it directly: “If this header has not been provided and data is not None, Content-Type: application/x-www-form-urlencoded will be added as a default.”

So the intuitive fix, deleting the line that sets the content type, swaps one wrong value for another wrong value. The 403 does not change, which makes it look like the content type was never the problem.

You can see it without touching S3 at all:

# show_default.py — what urllib actually sends
import urllib.request

def header_for(explicit):
    req = urllib.request.Request("https://example.invalid/upload", data=b"bytes", method="PUT")
    if explicit is not None:
        req.add_header("Content-Type", explicit)
    # urllib fills in its default when the handler prepares the request,
    # so ask the Request what it will send rather than reading .headers directly.
    return req.get_header("Content-type", "<absent, urllib will default it>")

print("explicit image/png :", header_for("image/png"))
print("not set            :", header_for(None))
print("explicit empty     :", repr(header_for("")))
explicit image/png : image/png
not set            : <absent, urllib will default it>
explicit empty     : ''

The second line is the one that matters: nothing is set on the request, so the handler supplies application/x-www-form-urlencoded on the way out. The third line shows the fix is representable; an empty string is a real header value, distinct from absence.

The upload

# upload.py
import urllib.error
import urllib.request
from pathlib import Path

def put_presigned(upload_url: str, path: str, content_type: str = "") -> int:
    """PUT bytes to a presigned URL.

    `content_type` must equal whatever the URL was signed with. Pass "" when it
    was signed with an empty content type, and set it explicitly either way so
    urllib cannot substitute its own default.
    """
    blob = Path(path).read_bytes()
    req = urllib.request.Request(upload_url, data=blob, method="PUT")
    req.add_header("Content-Type", content_type)
    try:
        with urllib.request.urlopen(req) as resp:
            return resp.status
    except urllib.error.HTTPError as e:
        if e.code == 403:
            raise SystemExit(
                "403 from the presigned PUT. The Content-Type you sent does not match "
                f"the one the URL was signed with (you sent {content_type!r})."
            ) from e
        raise

Passing the value in rather than hardcoding "" matters, because the correct value is a property of whoever signed the URL. Some services sign with a real type and reject the empty string just as firmly.

Verify it before you need it

Run the header probe first, since it needs no network and no credentials:

python3 show_default.py

Then confirm against your real signer with curl, which is useful because curl also has opinions about default headers. -H 'Content-Type:' with nothing after the colon removes the header entirely, while -H 'Content-Type: ' sends it empty:

curl -sS -X PUT --upload-file ./card.png \
  -H 'Content-Type: ' \
  -w '%{http_code}\n' -o /dev/null \
  "$PRESIGNED_URL"

A 200 means the signature matched. A 403 means you are still sending something other than what was signed, and the next thing to check is whether your client added a header you did not ask for.

Gotchas

Coverage will tell you this line is tested when it is not. The test suite recorded the URL and the body of the presigned PUT and never the headers, so the broken line was fully covered and the feature was dead in production at the same time. Coverage measures execution, not assertion. If a header carries meaning, assert on the header: record it in your fake and check it, then confirm the check fails when you revert the fix.

A bug that only fires on a code path you rarely take can ship for several releases. This one survived from the first release because the first posts had no image. Before calling a feature shipped, run the path that actually exercises it once, end to end, with the real service.

The 403 body is not always the error you are debugging. Signature mismatch, an expired URL, and a clock skew between your machine and the signer all surface the same way. Check expiry and system time before you start permuting headers, because those are cheap to rule out and produce identical symptoms.

An empty header value is not portable across clients. urllib sends the empty string, curl needs -H 'Content-Type: ' with the trailing space, and some HTTP clients drop empty headers entirely on the way out. Verify what your client puts on the wire rather than what your code says it set.

Sources

Changelog

  • feat(ghostwriter-x): 0.2.0 — improvement pass from the first image-bearing run (#91) (d1fd8ee)