fix(plugin): reject archive entry names the SDK would refuse to extract (#2757)

The host inspects a package to read manifest.yaml and requirements.txt before the
SDK extracts it. Inspection normalized entry names with only
replace('\\','/').strip('/'), so it accepted names its own extractor rejects
(absolute paths, '..' components, drive letters) and it silently kept the first
member when two normalized to the same path.

There was no traversal write, because the SDK's _extract_verified_zip validates
every member before writing and the host only reads members by ZipInfo. The bug
is the divergence: a package could pass inspection and then fail to install, and
the two rule sets could drift further as either side changes.

Inspection now applies the same rule set as the SDK, via
_normalize_archive_entry_name: reject empty/NUL names, absolute paths, any '..'
component and drive letters; reject a duplicate after normalization. '.' and
empty segments are still collapsed, so './manifest.yaml' and 'components//x.py'
keep working.

Tests: 13 cases covering each rejection, the duplicate, traversal taking
precedence over the duplicate check, and two positive cases (nested/unicode
layout, cosmetic separators).

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-22 22:04:36 +08:00
committed by GitHub
co-authored by multica-agent
parent 0b341086fb
commit da508f9603
2 changed files with 122 additions and 2 deletions
+29 -2
View File
@@ -17,6 +17,30 @@ _PLUGIN_METADATA_MAX_BYTES = 1024 * 1024
_PLUGIN_REQUIREMENTS_MAX_ENTRIES = 1000
def _normalize_archive_entry_name(filename: str) -> str:
"""Return the archive entry name in portable form, or reject it.
The same rule set the SDK applies when it extracts an artifact
(``runtime/plugin/artifact.py``). Inspection must reject exactly what
extraction rejects: otherwise a package can pass inspection and then fail to
install, and the two layers can drift apart as either side changes.
"""
if not filename or '\x00' in filename:
raise ValueError('Plugin archive contains an empty or NUL entry name')
portable = filename.replace('\\', '/')
parts = portable.split('/')
if portable.startswith('/') or any(part == '..' for part in parts):
raise ValueError(f'Plugin archive entry escapes the archive root: {filename}')
if len(parts[0]) >= 2 and parts[0][1] == ':':
raise ValueError(f'Plugin archive entry uses a drive letter: {filename}')
normalized = '/'.join(part for part in parts if part not in ('', '.'))
if not normalized:
raise ValueError(f'Plugin archive contains an empty entry path: {filename}')
return normalized
class ArchiveCertificateState(str, Enum):
"""Syntactic certificate declaration state; this is not verification."""
@@ -108,9 +132,12 @@ def inspect_plugin_archive(file_bytes: bytes, *, require_manifest: bool = True)
total_uncompressed += member.file_size
if total_uncompressed > _PLUGIN_ARCHIVE_MAX_TOTAL_BYTES:
raise ValueError('Plugin archive exceeds the uncompressed size limit')
normalized = member.filename.replace('\\', '/').strip('/')
normalized = _normalize_archive_entry_name(member.filename)
names.append(member.filename)
files.setdefault(normalized.lower(), member)
key = normalized.lower()
if key in files:
raise ValueError(f'Plugin archive contains a duplicate entry: {member.filename}')
files[key] = member
manifest_member = files.get('manifest.yaml') or files.get('manifest.yml')
if manifest_member is None: