Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .grype.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
7 changes: 7 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -156,6 +158,7 @@ RUN set -eux; \
libssl-dev \
make \
netbase \
patch \
tk-dev \
tzdata \
uuid-dev \
Expand All @@ -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 \
Expand All @@ -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; \
Expand Down
74 changes: 74 additions & 0 deletions backend/patches/cpython-3.13-cve-2026-82049.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
From b8f23e307097552eaea2604383a12ab280520d0d Mon Sep 17 00:00:00 2001
From: Stan Ulbrych <stan@python.org>
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 <stan@python.org>
---
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`.
116 changes: 102 additions & 14 deletions frontend/src/flow/WorkflowApiRequestEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<RequestSessionHarness session={session} changed={changed} startFullscreen />)
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()
Expand Down Expand Up @@ -666,20 +711,63 @@ function RequestSessionHarness({
<InspectorPresentationContext.Provider value={presentation}>
<button
onClick={() =>
update({
...definition,
nodes: [
restoreEditedNode(draft, {
...editorNode(draft),
config: {
...editorNode(draft).config,
api_definition_id: 'api-2',
api_version: 2,
request_overrides: {},
update(
{
...definition,
nodes: [
restoreEditedNode(draft, {
...editorNode(draft),
name: draft.name === node.name ? '临时名称' : node.name,
}),
],
},
'node',
)
}
>
切换节点名称测试
</button>
<button
onClick={() =>
update(
{
...definition,
edges: [
...definition.edges,
{
id: 'audit-edge',
source: 'start',
target: draft.id,
condition: null,
mappings: [],
},
}),
],
})
],
},
'edges',
)
}
>
修改连线测试
</button>
<button
onClick={() =>
update(
{
...definition,
nodes: [
restoreEditedNode(draft, {
...editorNode(draft),
config: {
...editorNode(draft).config,
api_definition_id: 'api-2',
api_version: 2,
request_overrides: {},
},
}),
],
},
'node',
)
}
>
切换接口测试
Expand All @@ -692,7 +780,7 @@ function RequestSessionHarness({
artifacts={[artifact]}
editable
onUpdate={(updated) =>
update({ ...definition, nodes: [restoreEditedNode(draft, updated)] })
update({ ...definition, nodes: [restoreEditedNode(draft, updated)] }, 'node')
}
/>
</InspectorPresentationContext.Provider>
Expand Down
Loading
Loading