fix(865): fail closed on non-constructible typed scalars and bare indicators
Round-8: the PyYAML-absent conservative recognizer proved STRUCTURE and implicit-resolver TYPE but not CONSTRUCTOR VALIDITY. PyYAML safe_load raises a constructor ValueError on the WHOLE document when a plain scalar matches a typed implicit resolver but is not constructible (e.g. bad calendar date 2023-99-99, empty-radix ints 0b_/0x_), yielding no token; the fallback instead treated such a scalar as a null-equivalent, ignored the malformed key, and still emitted the valid login token -- a credential fail-open in the dangerous direction. _scalar now checks constructibility via _constructible (replicating PyYAML 6.0.3 construct_yaml_int/float/timestamp, stdlib-only): a typed scalar that is not constructor-valid returns _FAIL, which the parser turns into _Bail so the WHOLE document fails closed exactly as PyYAML does. int and timestamp resolver patterns are byte-identical to PyYAML's; the float pattern is a strict superset whose extras are all float()-constructible (fuzz-verified 0/400k raise), so it never fails closed where PyYAML would emit. Also fail closed on plain scalars beginning with an indicator a plain scalar may not start with: '%' (directive) and ',' (flow), and the conditional block indicators '-'/'?'/':' when followed by whitespace or end-of-value (bare sequence/complex-key/value indicators PyYAML rejects), while '-x'/'-1'/'?x'/':x' remain valid plain strings. Differential fuzz through the real function: 0 fail-opens across 1499 diverse non-tab cases; targeted fixtures assert fallback == PyYAML == fail-closed for 2023-99-99, 2023-13-01, bad-hour timestamp, 0b_, 0x_, 0x__, %broken, ',bad', bare '-'/'- '/'? key', and assert the token STILL resolves for constructible/ string look-alikes (valid date/datetime, 0o_, 4.e8, 0x1f, -x, :x). Both PyYAML present and forced-absent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -612,6 +612,7 @@ get_gitea_token_for_login() {
|
||||
[[ -f "$config_file" ]] || return 1
|
||||
|
||||
LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 - "$config_file" <<'PY'
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -671,6 +672,138 @@ def _implicit_nonstring(text):
|
||||
)
|
||||
|
||||
|
||||
# PyYAML 6.0.3 SafeConstructor timestamp regexp (yaml/constructor.py). The
|
||||
# constructor RE-parses a timestamp-tagged scalar with THIS pattern and then
|
||||
# builds a datetime.date/datetime, which raises ValueError for an out-of-range
|
||||
# calendar field (e.g. month 99, hour 25). _IMPLICIT_TIMESTAMP (the RESOLVER
|
||||
# pattern) is byte-identical to PyYAML's resolver, so anything it tags is also
|
||||
# tagged by PyYAML and re-matched here.
|
||||
_TIMESTAMP_CONSTRUCT = re.compile(
|
||||
r"""^(?P<year>[0-9][0-9][0-9][0-9])
|
||||
-(?P<month>[0-9][0-9]?)
|
||||
-(?P<day>[0-9][0-9]?)
|
||||
(?:(?:[Tt]|[ \t]+)
|
||||
(?P<hour>[0-9][0-9]?)
|
||||
:(?P<minute>[0-9][0-9])
|
||||
:(?P<second>[0-9][0-9])
|
||||
(?:\.(?P<fraction>[0-9]*))?
|
||||
(?:[ \t]*(?P<tz>Z|(?P<tz_sign>[-+])(?P<tz_hour>[0-9][0-9]?)
|
||||
(?::(?P<tz_minute>[0-9][0-9]))?))?)?$""",
|
||||
re.X,
|
||||
)
|
||||
|
||||
|
||||
def _int_constructible(text):
|
||||
# Replicate PyYAML SafeConstructor.construct_yaml_int and report whether it
|
||||
# would succeed. A resolver-tagged int whose radix body is empty after
|
||||
# underscore removal (e.g. "0b_", "0x_", "0x__") makes int(base) raise, so
|
||||
# PyYAML fails the WHOLE document -> the fallback must fail closed too.
|
||||
value = text.replace("_", "")
|
||||
if value[:1] in ("+", "-"):
|
||||
value = value[1:]
|
||||
if value == "0":
|
||||
return True
|
||||
try:
|
||||
if value.startswith("0b"):
|
||||
int(value[2:], 2)
|
||||
elif value.startswith("0x"):
|
||||
int(value[2:], 16)
|
||||
elif value[:1] == "0":
|
||||
int(value, 8)
|
||||
elif ":" in value:
|
||||
[int(part) for part in value.split(":")]
|
||||
else:
|
||||
int(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _float_constructible(text):
|
||||
# Replicate PyYAML SafeConstructor.construct_yaml_float. Retained for the
|
||||
# WHOLE-document invariant even though PyYAML's float-tagged set is always
|
||||
# float()-constructible: the fallback's float RESOLVER pattern is a strict
|
||||
# superset of PyYAML's (it also matches unsigned-exponent spellings PyYAML
|
||||
# keeps as strings), and every such extra is likewise constructible, so this
|
||||
# never fails closed where PyYAML would emit a token.
|
||||
value = text.replace("_", "").lower()
|
||||
if value[:1] in ("+", "-"):
|
||||
value = value[1:]
|
||||
if value in (".inf", ".nan"):
|
||||
return True
|
||||
try:
|
||||
if ":" in value:
|
||||
[float(part) for part in value.split(":")]
|
||||
else:
|
||||
float(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _timestamp_constructible(text):
|
||||
# Replicate PyYAML SafeConstructor.construct_yaml_timestamp: build the same
|
||||
# datetime.date/datetime and report whether it raises. Returns False for an
|
||||
# out-of-range calendar field (month/day/hour/...), matching PyYAML's
|
||||
# whole-document ValueError.
|
||||
match = _TIMESTAMP_CONSTRUCT.match(text)
|
||||
if not match:
|
||||
return False
|
||||
values = match.groupdict()
|
||||
try:
|
||||
year = int(values["year"])
|
||||
month = int(values["month"])
|
||||
day = int(values["day"])
|
||||
if not values["hour"]:
|
||||
datetime.date(year, month, day)
|
||||
return True
|
||||
hour = int(values["hour"])
|
||||
minute = int(values["minute"])
|
||||
second = int(values["second"])
|
||||
fraction = 0
|
||||
if values["fraction"]:
|
||||
frac = values["fraction"][:6]
|
||||
frac += "0" * (6 - len(frac))
|
||||
fraction = int(frac)
|
||||
tzinfo = None
|
||||
if values["tz_sign"]:
|
||||
tz_hour = int(values["tz_hour"])
|
||||
tz_minute = int(values["tz_minute"] or 0)
|
||||
delta = datetime.timedelta(hours=tz_hour, minutes=tz_minute)
|
||||
if values["tz_sign"] == "-":
|
||||
delta = -delta
|
||||
tzinfo = datetime.timezone(delta)
|
||||
elif values["tz"]:
|
||||
tzinfo = datetime.timezone.utc
|
||||
datetime.datetime(
|
||||
year, month, day, hour, minute, second, fraction, tzinfo=tzinfo
|
||||
)
|
||||
except (ValueError, OverflowError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _constructible(text):
|
||||
# WHOLE-DOCUMENT INVARIANT: the fallback resolves the SAME login token as
|
||||
# PyYAML safe_load or fails closed -- never less conservative -- INCLUDING
|
||||
# when PyYAML raises a CONSTRUCTOR error anywhere in the document. A plain
|
||||
# scalar can match a typed implicit resolver (int/float/timestamp) yet NOT be
|
||||
# constructible (e.g. 2023-99-99, 0b_, 0x_); PyYAML then raises on the whole
|
||||
# load and yields no token, so the fallback MUST fail closed for the whole
|
||||
# document too. This returns True only when PyYAML's constructor would build
|
||||
# the scalar (null/bool token sets are always constructible), else False so
|
||||
# the caller fails closed. `text` is assumed to satisfy _implicit_nonstring.
|
||||
if _IMPLICIT_NULL.match(text) or _IMPLICIT_BOOL.match(text):
|
||||
return True
|
||||
if _IMPLICIT_TIMESTAMP.match(text):
|
||||
return _timestamp_constructible(text)
|
||||
if _IMPLICIT_INT.match(text):
|
||||
return _int_constructible(text)
|
||||
if _IMPLICIT_FLOAT.match(text):
|
||||
return _float_constructible(text)
|
||||
return True
|
||||
|
||||
|
||||
# Sentinel: "outside the supported subset -> fail closed". Distinct from a
|
||||
# genuine null (None), which is a valid resolved value.
|
||||
_FAIL = object()
|
||||
@@ -724,14 +857,31 @@ def _scalar(raw):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
if value[0] in _FLOW or value[0] in ("|", ">", "?", "@", "`", '"', "'"):
|
||||
return _FAIL # flow / block-scalar / reserved / anchor / quote indicator
|
||||
if value[0] in _FLOW or value[0] in ("|", ">", "?", "@", "`", '"', "'", "%", ","):
|
||||
return _FAIL # flow / block-scalar / reserved / directive / anchor / quote
|
||||
if value[0] in ("-", "?", ":") and (len(value) == 1 or value[1] in (" ", "\t")):
|
||||
# A bare block indicator, not a plain scalar: '-'/'- ' opens a sequence
|
||||
# entry, '?'/'? ' a complex mapping key, ':'/': ' a mapping value -- all
|
||||
# illegal in a value position, where PyYAML raises a scanner error on the
|
||||
# whole document. "-x"/"-1"/"?x"/":x" (indicator NOT followed by space)
|
||||
# remain valid plain scalars and fall through. Fail closed on the bare
|
||||
# indicator so the fallback never emits a token PyYAML would refuse.
|
||||
return _FAIL
|
||||
if any(ch in _FLOW for ch in value):
|
||||
return _FAIL
|
||||
if ": " in value or value.endswith(":") or "\t" in value:
|
||||
return _FAIL # nested-mapping-in-scalar / ambiguous
|
||||
if _implicit_nonstring(value):
|
||||
return None # non-string implicit type -> null-equivalent for a token
|
||||
# A plain scalar PyYAML would tag as a non-string (null/bool/int/float/
|
||||
# timestamp). If PyYAML's CONSTRUCTOR would build it, the value is a
|
||||
# non-string -> null-equivalent for a token field: return None and keep
|
||||
# parsing (as before). But if it matches a typed implicit resolver yet is
|
||||
# NOT constructible (e.g. 2023-99-99, 0b_, 0x_), PyYAML raises on the
|
||||
# WHOLE document and yields no token, so the fallback MUST fail closed
|
||||
# for the whole document too -> _FAIL (which the caller turns into _Bail).
|
||||
if not _constructible(value):
|
||||
return _FAIL
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user