fix(nodes): 访问策略激活时逐跳重定向在 307/308 丢请求体——POST/PUT 静默变空体提交
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- Half a day
- Newbie friendliness
- 62/100
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
优先级 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 带过跳转:
- 同步路径
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是否被降级来决定。 - 异步路径
_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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Similar issues
-
Pull-through from gcr.io returns 500 when pulling using podman/buildahPossibly taken A pull request linked to this issue is open or already merged. OpenIssue Triage-Needed
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
pulp/pulp_container#2581 ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 85/100
521xueweihan/HelloGitHub#4040 ·
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 64/100
ClanGenOfficial/clangen#6313 ·
Maintainers usually reply within 2 days
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Maintainers usually reply within 1 day
-
bug(trainer): CustomTrainer func_args with non literal values fail inside the TrainJobPossibly taken @avanishkasar claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
kubeflow/sdk#879 · 1 comment ·
Maintainers usually reply within 1 day