Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

fix(nodes): 访问策略激活时逐跳重定向在 307/308 丢请求体——POST/PUT 静默变空体提交

Closed
#82 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
Half a day
Newbie friendliness
62/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
python
Domain
backend

Research direction

Start in plaita/node/http.py: the sync redirect loop in handle_request (around lines 482-494) and the async _send_async hop loop (around lines 561-585) both rebuild the request without the body. Read _build_request_params() to see how the encoded body is produced, then run /tmp/pnb-repro/t4_307.py to confirm /next receives an empty POST. Done means a 307 or 308 test in tests/unit/test_reviewfix_c_http.py shows the body preserved on both paths, while 301/302/303 still switch to GET without a body.

Written by the indexing model from the issue text.

Description

needs-human

优先级 P1 · 依赖:无

基线 HEAD = 65f48f2

Summary

HTTP 节点在访问策略(allowedHosts / deniedHosts / blockPrivateNetworks)激活时改为「逐跳校验 + 手动跟随」重定向,但手动重建的请求不携带原始 body:同步路径重建 requests.Request 时没有 data=,异步路径第 2 跳起写死 data=None,而 307/308 又保持原方法不变。结果是 POST/PUT 在 307/308 后被静默降级为空体请求。

1. 策略开关判定
plaita/node/http.py:429-431
    @property
    def _restrictions_active(self) -> bool:
        return bool(self.allowed_hosts or self.denied_hosts or self.block_private_networks)
2. 同步路径:首跳禁自动重定向
plaita/node/http.py:469-475
            if self._restrictions_active:
                self._check_policy(request.url)
            # restricted 时首跳必须禁自动重定向:requests 的 resolve_redirects
            # 会在 send 内部跟完 302,下面的逐跳策略校验就全部失效
            # (2026-10 评审发现的现状 bug,非本次共享 session 引入)。
            response = self.c.send(request, timeout=self.request_timeout,
                                   allow_redirects=not self._restrictions_active)
3. 同步路径:重建请求丢失 body

method 只在 301/302/303 降级为 GET(:483-484),307/308 保持 POST;但 :488-492 重建的 requests.Request 只有 method/url/headers,没有 data=:

plaita/node/http.py:482-494
                    method = self.method
                    if response.status_code in (301, 302, 303) and method.upper() != "HEAD":
                        method = "GET"
                    # 跨源重定向剥离凭据类头(host/content-length 恒剥离)
                    hop_headers = _strip_sensitive_headers(
                        request.headers, response.url, next_url)
                    request = requests.Request(
                        method=method, url=next_url,
                        headers={k: v for k, v in hop_headers.items()
                                 if k.lower() not in ("host", "content-length")},
                    ).prepare()
                    response = self.c.send(request, timeout=self.request_timeout, allow_redirects=False)
4. 异步路径:第 2 跳起 data=None,且 307/308 不降级

data 只在第 1 跳传入(:566),for hop in range(...) 第 2 次迭代即 None;:570 把 301/302/303/307/308 全部纳入手动跳转分支,:574-575 又只对 301/302/303 改 GET——因此 307/308 得到「POST 方法 + 空 body」:

plaita/node/http.py:561-584
            for hop in range(self.max_redirects + 1):
                async with session.request(
                    method=current_method,
                    url=current_url,
                    headers=headers,
                    data=data if hop == 0 else None,
                    timeout=timeout,
                    allow_redirects=follow,
                ) as response:
                    if response.status in (301, 302, 303, 307, 308) and not follow:
                        next_url = urljoin(str(response.url), response.headers.get("Location", ""))
                        self._check_policy(next_url)
                        current_method = self.method
                        if response.status in (301, 302, 303) and current_method.upper() != "HEAD":
                            current_method = "GET"
                        # 跨源重定向剥离凭据类头(后续跳沿用裁剪后的头)
                        headers = _strip_sensitive_headers(
                            headers, str(response.url), next_url)
                        current_url = next_url
                        # 快照跟随实际尝试的下一跳(错误帧里带真实 url/headers)
                        raw_request = HttpRequestInfo(
                            method=current_method, url=current_url,
                            headers=headers, body=None,
                        )
5. 异步路径就是引擎主路径

HTTP 定义了 arun,核心 runner 对协程节点走 _run_async_node,即线上执行的正是第 4 节的代码:

plaita/node/http.py:756
    async def arun(self, execution):
plaita/core/runner.py:323-324
        if hasattr(node, "arun") and asyncio.iscoroutinefunction(node.arun):
            return await self._run_async_node(node, timeout_ms)
6. 复现(实跑)

本地 http.server:/start 对 POST 返回 307 → /next,记录各路径收到的 (method, body)。

$ /tmp/plaita-audit-venv/bin/python /tmp/pnb-repro/t4_307.py
SYNC  err: None
SYNC  received: {'/start': ('POST', b'{"k":"v"}'), '/next': ('POST', b'')}
ASYNC err: None
ASYNC received: {'/start': ('POST', b'{"k":"v"}'), '/next': ('POST', b'')}

同步/异步两条路径都把首跳的 b'{"k":"v"}' 丢掉了:/next 收到 POST 但 body 为空。

对照实验(同一组请求,不配置任何访问策略,其余不变):

$ /tmp/plaita-audit-venv/bin/python /tmp/pnb-repro/t4b_norestrict.py
SYNC unrestricted: {'/start': ('POST', b'{"k":"v"}'), '/next': ('POST', b'{"k":"v"}')}
ASYNC unrestricted: {'/start': ('POST', b'{"k":"v"}'), '/next': ('POST', b'{"k":"v"}')}

不激活策略时 body 正常保留,说明缺陷由「策略激活 → 手动跟随」这条路径独有,即安全开关会改变业务语义。

7. 现有测试未覆盖

tests/unit/test_reviewfix_c_http.py 的重定向用例全是 do_GET:

$ grep -rn "def do_POST\|def do_PUT" tests/
tests/integration/test_http.py:432:            def do_POST(self):

tests/unit/test_reviewfix_c_http.py 中出现的重定向状态码只有 302(:48、:52),全仓 tests/ 无 307/308 用例,因此任何 body 保留断言都不存在,缺陷未被兜住。

影响

  • 触发角色:按 MIGRATION.md:24-26 配置了 allowedHosts / deniedHosts / blockPrivateNetworks 的部署方(多租户/公网暴露场景的推荐配置)。未配策略时不会命中。
  • 触发链路:流程里任何 HTTP 节点以 method=POST/PUT 提交数据,且目标服务用 307/308 做跳转(常见的登录跳转、尾斜杠归一、https 强制跳转、CDN/网关重定向)。引擎主路径(arun)必然走到异步分支,故异步侧影响是默认可达的。
  • 代价:数据静默丢失。服务端收到的是一个「方法正确、body 为空」的请求,通常返回 200/400 之类结果而不会报连接错误——流程会认为调用成功,实际提交的是空表单/空 JSON/空记录,可能写入脏数据(例如创建了空对象、更新了空字段),且日志里看不出任何异常(err: None)。
  • 不修的后果:只要运营者按文档开启访问策略,原本正常的 POST/PUT 业务流会在任何 307/308 跳转处开始静默丢数据,且问题随「开启安全策略」这一动作出现,排查方向容易被误导到目标服务端。

建议

修复点集中在 plaita/node/http.py,两处都需把 body 带过跳转:

  1. 同步路径 handle_request(:482-494):重建 requests.Request 时补回 body。最小修法是在构造参数中加入 data=data,其中 data 取自 _build_request_params()(:437-453,已把 self.body 编码为 bytes)——self.new_request(ctx) 内部已用过同一份参数,建议改为复用该函数的返回值而不是重新推导。注意 301/302/303 降级为 GET 时应不带 body(现在的行为对这三者是碰巧正确的),因此 data 需按 method 是否被降级来决定。
  2. 异步路径 _send_async(:561-585):data=data if hop == 0 else None 应改为「仅在方法被降级为 GET 时置 None,否则沿用原始 data」;:573-575 的 current_method 逻辑保持不变(307/308 继续保留原方法)。同时 :581-584 的错误快照 body=None 应同步填为实际发送的 body,否则错误帧里的请求快照与真实请求不符。

验收条件:

  • 在 tests/unit/test_reviewfix_c_http.py 新增 307(与 308)用例:起一个 do_POST 的 handler,/start 返回 307 → /next,配置 allowed_hosts=['127.0.0.1'],同步与异步各请求一次,断言 /next 收到 ('POST', b'{"k":"v"}')(当前实测为 ('POST', b''))。
  • 同一测试同时断言 302/303 仍降级为 GET 且不带 body,防止修复时把「降级」和「保留 body」两条语义混在一起。
  • 复现脚本 /tmp/pnb-repro/t4_307.py 的输出应从 /next': ('POST', b'') 变为 /next': ('POST', b'{"k":"v"}');t4b_norestrict.py 的输出(不配策略)应保持不变,作为「开关不改变语义」的回归锚点。

边界

  • 只修 body 在逐跳重定向中的保留问题。不涉及同一函数里其他安全语义,例如 _check_policy 的 CIDR 放行、_strip_sensitive_headers 的凭据裁剪、max_redirects 上限——如需改动请另开单。
  • 与 #31 同文件(plaita/node/http.py)相邻但不重叠:#31 讲的是 SSRF 策略默认关闭、校验与连接两次 DNS 解析(TOCTOU)、响应体无大小上限;本条讲的是策略已开启时手动跟随路径丢 body。#31 的修法(默认收紧)只会扩大本条的触发面,两者应各自独立修复。
  • 不改访问策略的默认值,也不做「默认开启策略」这类行为变更——那属于 #31 的决策范围。
  • 不顺手重构 http.py 的重定向循环结构(sync/async 合并、抽取公共跳转函数等);本次只要求两处重建请求时正确携带 body。
Dominant language
Python
Stars
0
Forks
1
Avg merge
3h
Merged PRs (30d)
6

Getting set up

This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.