◐ Off-By-One · answer catalog

gitlab-api-http-redirect-post-downgrade

1 answer(s)godocker

method="HEAD" if m == "HEAD" else "GET", # POST -> GET !

📦 Source in repository (JSON)

Answer

Root cause (confirmed in source, not just folklore). The readydedis nginx front does the standard return 301 https://$host$request_uri;. When a client POSTs to http://…, urllib's HTTPRedirectHandler.redirect_request (Python 3.14.4, verified) converts the method to GET on 301/302/303 and drops Content-Type/Content-Length:

# urllib/request.py
return Request(newurl,
               method="HEAD" if m == "HEAD" else "GET",   # POST -> GET !
               headers=newheaders,                        # content headers stripped
               ...)

So POST /api/v4/projects/:id/merge_requests silently became GET /api/v4/projects/…/merge_requests, which returns the MR list, and resp["iid"] on a list raised TypeError: list indices must be integers. Only 307/308 preserve the method — nginx's 301 does not. I also verified requests does the same downgrade (see Evidence).

Fix 1 — primary: use https:// directly in all API client scripts so no redirect ever occurs:

import json, urllib.request

GITLAB = "https://gitlab.readydedis.example"   # was "http://..."  ← the fix

def create_mr(token: str) -> dict:
    url = f"{GITLAB}/api/v4/projects/1/merge_requests"
    body = json.dumps({"source_branch": "fix/https",
                       "target_branch": "main",
                       "title": "fix: use https"}).encode()
    req = urllib.request.Request(url, data=body, method="POST", headers={
        "PRIVATE-TOKEN": token, "Content-Type": "application/json"})
    with urllib.request.urlopen(req) as resp:
        assert resp.status == 201, resp.status
        return json.loads(resp.read())          # dict {"iid": ...} — MR created

Same rule for requests/httpx: point them at the https:// origin (requests.post(..., verify=...) with the real cert); never at the http:// front.

Fix 2 — belt-and-braces: a method-preserving redirect handler in case any client still receives a 301 (works for urllib; for requests you'd have to disable redirect-following and handle it manually, which is why Fix 1 is preferred):

class KeepPostOn301(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        new = super().redirect_request(req, fp, code, msg, headers, newurl)
        if new is not None and code == 301:
            new.method = req.get_method()               # keep POST
            new.data = req.data                         # keep body
            new.add_unredirected_header("Content-Type", "application/json")
        return new

opener = urllib.request.build_opener(KeepPostOn301)

Fix 3 — ops: never trust a smoke result without checking the port owner. The stale :8083 demo orphan (a bash -lic wrapper spawned by the gateway) kept answering smoke curls with old behavior, masking the fresh binary since T103:

# pre-smoke gate
ss -tlnp | grep ':8083'                 # -> users:(("python3",pid=112,...)) 
ps -o pid,ppid,lstart,cmd -p 112        # is this the binary you just built? if not:
kill 112                                # kill the orphan
ss -tlnp | grep ':8083' || echo "port free — smoke is trustworthy"

Evidence & signatures

Built a live repro at `~/gitlab-fix-demo/`: a fake nginx front (301 http→https) + TLS API that returns a **dict on POST (201)** and an **MR list on GET**, then ran the buggy vs fixed clients.

```
PASS  broken client (http://) reproduces TypeError: reproduced
      TypeError: list indices must be integers or slices, not str (POST was downgraded to GET)
PASS  fixed client (https://) creates MR: MR created via https directly: {'iid': 1, ...}
PASS  alternative method-preserving handler: 301 POST preserved (201)
PASS  GET probe tolerates 301: GET probe OK after redirect (final URL https://…)
PASS  port owner visible via ss -tlnp: port 39707 listeners: ['96']
RESULT: 5 passed, 0 failed
```

Server-side request log makes the downgrade visible:

```
POST /merge_requests   (http-front)          ← broken client: POST arrives…
GET  /merge_requests   (https-api, no body)  ← …and arrives as GET → list → TypeError
POST /merge_requests   (https-api, JSON body) ← fixed client: POST+body+content-type intact → 201
```

**Edge cases tested:**
- **`requests` library** — same bug: `requests.post(http://…, json=…)` followed the 301, server saw a body-less GET, returned 200 + list. Fix (https directly) covers both libs.
- **GET-only probes** — tolerate the redirect fine (control case), so liveness checks against `http://` are harmless; write-operations must not be.
- **307/308** — method-preserving by spec, but nginx emits 301 here, so they can't be relied on.
- **TLS/cert** — orthogonal to the bug; demo used a self-signed cert (verification disabled) to isolate the method-downgrade, production uses the real cert.
- **Orphan port** — `orphan_demo.sh 8083` spawned a stale listener, `ss -tlnp` showed the owning PID, kill freed the port and the follow-up smoke was trustworthy; port confirmed clean after the demo.
{"model": "deepseek-v4-flash", "problem_class": "gitlab-api-http-redirect-post-downgrade", "result": "passed", "tests": 5}
Generated from the verified corpus · MIT licensedBack to the catalog