diff --git a/.grype.yaml b/.grype.yaml index 8fbbcafd..84b1046a 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -11,3 +11,8 @@ ignore: package: { name: python, version: 3.13.15, type: binary } - vulnerability: CVE-2026-15308 package: { name: python, version: 3.13.15, type: binary } + # The Dockerfile applies python/cpython@b8f23e307097552eaea2604383a12ab280520d0d + # and runs its regression test before installation. Grype still identifies + # the patched runtime by its unchanged 3.13.15 version string. + - vulnerability: CVE-2026-82049 + package: { name: python, version: 3.13.15, type: binary } diff --git a/backend/Dockerfile b/backend/Dockerfile index be052c6e..42767ed2 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -128,6 +128,8 @@ RUN set -eux; \ FROM debian:trixie-slim AS python-runtime +COPY patches/cpython-3.13-cve-2026-82049.patch /tmp/cpython-3.13-cve-2026-82049.patch + ENV PATH="/usr/local/bin:$PATH" \ PYTHON_VERSION="3.13.15" \ PYTHON_SHA256="1e66a7945a48390ee4c2a4268a0e4185884059a13c4aab6d148aa208deea4a76" @@ -156,6 +158,7 @@ RUN set -eux; \ libssl-dev \ make \ netbase \ + patch \ tk-dev \ tzdata \ uuid-dev \ @@ -176,6 +179,9 @@ RUN set -eux; \ mkdir -p /usr/src/python; \ tar --extract --directory /usr/src/python --strip-components=1 --file python.tar.xz; \ rm python.tar.xz; \ + patch --directory=/usr/src/python --strip=1 \ + < /tmp/cpython-3.13-cve-2026-82049.patch; \ + rm /tmp/cpython-3.13-cve-2026-82049.patch; \ cd /usr/src/python; \ build_arch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)"; \ ./configure \ @@ -186,6 +192,7 @@ RUN set -eux; \ --enable-shared \ --with-ensurepip; \ make -j "$(nproc)"; \ + LD_LIBRARY_PATH=. ./python -m test -m test_sneaky_hardlink_relocation test_tarfile; \ make install; \ cd /; \ rm -rf /usr/src/python; \ diff --git a/backend/patches/cpython-3.13-cve-2026-82049.patch b/backend/patches/cpython-3.13-cve-2026-82049.patch new file mode 100644 index 00000000..8189a528 --- /dev/null +++ b/backend/patches/cpython-3.13-cve-2026-82049.patch @@ -0,0 +1,74 @@ +From b8f23e307097552eaea2604383a12ab280520d0d Mon Sep 17 00:00:00 2001 +From: Stan Ulbrych +Date: Thu, 10 Sep 2026 11:06:35 +0100 +Subject: [PATCH] [3.13] gh-157190: Fix tarfile `data`/`tar` filter bypass via + hard link to a symlink (GH-157191) (GH-157192) + +(cherry picked from commit 480ea4aab898291132c071645ec9ef26dab69def) +The backport to 3.13 and below includes a NEWS entry. + +Co-authored-by: Stan Ulbrych +--- + Lib/tarfile.py | 6 +++++- + Lib/test/test_tarfile.py | 18 ++++++++++++++++++ + ...6-09-06-11-02-46.gh-issue-157190.tarhln.rst | 5 +++++ + 3 files changed, 28 insertions(+), 1 deletion(-) + create mode 100644 Misc/NEWS.d/next/Security/2026-09-06-11-02-46.gh-issue-157190.tarhln.rst + +diff --git a/Lib/tarfile.py b/Lib/tarfile.py +index 3c59eac5d9a0f11..e68d7bbf3afb79c 100755 +--- a/Lib/tarfile.py ++++ b/Lib/tarfile.py +@@ -2738,7 +2738,11 @@ def makelink_with_filter(self, tarinfo, targetpath, + if os.path.lexists(targetpath): + # Avoid FileExistsError on following os.link. + os.unlink(targetpath) +- os.link(tarinfo._link_target, targetpath) ++ # Resolve the target so the hard link points to the file ++ # itself. Otherwise os.link() may duplicate a symlink to a ++ # shallower location, where it's relative target escapes the ++ # destination directory. (CVE-2026-82049) ++ os.link(os.path.realpath(tarinfo._link_target), targetpath) + return + except symlink_exception: + keyerror_to_extracterror = True +diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py +index 86fe8efac2e3495..34e9fb587725cb3 100644 +--- a/Lib/test/test_tarfile.py ++++ b/Lib/test/test_tarfile.py +@@ -4452,6 +4452,24 @@ def test_sneaky_hardlink_fallback_deep(self): + self.expect_file("a/b/s", symlink_to=os.path.join('..', 'escape')) + self.expect_file("s", symlink_to=os.path.join('..', 'escape')) + ++ @symlink_test ++ @os_helper.skip_unless_hardlink ++ def test_sneaky_hardlink_relocation(self): ++ with ArchiveMaker() as arc: ++ arc.add("a/escape", content="decoy") ++ arc.add("a/b/s", symlink_to=os.path.join("..", "escape")) ++ arc.add("s", hardlink_to=os.path.join("a", "b", "s")) ++ ++ for filter in 'data', 'tar': ++ with self.subTest(filter), self.check_context(arc.open(), filter): ++ self.expect_file("a/escape", content="decoy") ++ if os_helper.can_symlink(): ++ self.expect_file("a/b/s", symlink_to=os.path.join('..', 'escape')) ++ else: ++ self.expect_file("a/b/s", content="decoy") ++ self.expect_file("s", content="decoy") ++ self.assertFalse((self.destdir / "s").is_symlink()) ++ + @symlink_test + def test_exfiltration_via_symlink(self): + # (CVE-2025-4138) +diff --git a/Misc/NEWS.d/next/Security/2026-09-06-11-02-46.gh-issue-157190.tarhln.rst b/Misc/NEWS.d/next/Security/2026-09-06-11-02-46.gh-issue-157190.tarhln.rst +new file mode 100644 +index 000000000000000..3dffa3bf5b5a5a8 +--- /dev/null ++++ b/Misc/NEWS.d/next/Security/2026-09-06-11-02-46.gh-issue-157190.tarhln.rst +@@ -0,0 +1,5 @@ ++Fixed a vulnerability in the :mod:`tarfile` ``data`` and ``tar`` extraction ++filters where a crafted archive using a hard link to a symbolic link could ++change the permissions and modification time of a file outside the ++destination directory, and expose its contents inside the extracted tree. ++This addresses :cve:`2026-82049`. diff --git a/frontend/src/flow/WorkflowApiRequestEditor.test.tsx b/frontend/src/flow/WorkflowApiRequestEditor.test.tsx index c73e935f..938c239c 100644 --- a/frontend/src/flow/WorkflowApiRequestEditor.test.tsx +++ b/frontend/src/flow/WorkflowApiRequestEditor.test.tsx @@ -504,6 +504,51 @@ describe('WorkflowApiRequestEditor', () => { expect(session.unsafe.size).toBe(0) }) + it('keeps an unfinished request draft when only an edge changes', async () => { + const user = userEvent.setup() + const session = new DraftSession() + const changed = vi.fn() + vi.mocked(getApiDetail).mockResolvedValue({ + ...detail, + version: { ...detail.version, body_kind: 'json', body: { initial: true } }, + }) + render() + await screen.findByText('继承接口模板 v3') + await user.click(screen.getByRole('tab', { name: 'Body' })) + await user.click(within(screen.getByRole('tabpanel')).getByText('节点自定义')) + fireEvent.change(screen.getByRole('textbox', { name: 'JSON Body' }), { + target: { value: '{"pending":' }, + }) + + await user.click(screen.getByRole('button', { name: '切换节点名称测试' })) + await user.click(screen.getByRole('button', { name: '切换节点名称测试' })) + expect([...session.nodeEditors.values()][0].requestDirty).toBe(true) + expect(session.unsafe.size).toBe(1) + + await user.click(screen.getByRole('button', { name: '修改连线测试' })) + expect(screen.getByRole('textbox', { name: 'JSON Body' })).toHaveValue('{"pending":') + expect([...session.nodeEditors.values()][0].requestDraft).toMatchObject({ + activeTab: 'body', + fields: { body_text: '{"pending":' }, + }) + expect(changed).toHaveBeenCalledTimes(1) + expect(changed.mock.calls[0][0].nodes[0]).toEqual({ + ...node, + config: { ...node.config, request_overrides: {} }, + }) + + fireEvent.change(screen.getByRole('textbox', { name: 'JSON Body' }), { + target: { value: '{"ready":true}' }, + }) + await user.click(screen.getByRole('button', { name: '保存节点配置' })) + await waitFor(() => expect(changed).toHaveBeenCalledTimes(2)) + expect(changed.mock.calls[1][0].edges).toEqual(changed.mock.calls[0][0].edges) + expect(changed.mock.calls[1][0].nodes[0].config.request_overrides.body).toEqual({ + kind: 'json', + value: { ready: true }, + }) + }) + it('applies request edits after restoring the fullscreen inspector to quick mode', async () => { const user = userEvent.setup() const session = new DraftSession() @@ -666,20 +711,63 @@ function RequestSessionHarness({ + +