Skip to content

Raise BadTTL for non-decimal Unicode digits in dns.ttl.from_text()#1279

Merged
rthalley merged 1 commit into
rthalley:mainfrom
Sanjays2402:fix/ttl-non-decimal-unicode-digits
Jul 3, 2026
Merged

Raise BadTTL for non-decimal Unicode digits in dns.ttl.from_text()#1279
rthalley merged 1 commit into
rthalley:mainfrom
Sanjays2402:fix/ttl-non-decimal-unicode-digits

Conversation

@Sanjays2402

Copy link
Copy Markdown
Contributor

What

dns.ttl.from_text() raises a bare ValueError instead of the documented dns.ttl.BadTTL when the text contains a non-decimal Unicode "digit". This also leaks out of the public zone-file parser as a raw ValueError rather than a clean dns.exception.SyntaxError.

Root cause

from_text() gates its integer parsing on str.isdigit() and then calls int():

if text.isdigit():
    total = int(text)
...
    for c in text:
        if c.isdigit():
            current *= 10
            current += int(c)

str.isdigit() returns True for Unicode characters in category No (e.g. the superscript ² U+00B2, or the Ethiopic digit U+1369), but int() only accepts decimal digits. So such input passes the isdigit() gate and then blows up inside int() with a bare ValueError, escaping the parser's own validation.

The function's docstring explicitly promises :raises dns.ttl.BadTTL:, so this is a contract violation.

Before (on main)

>>> import dns.ttl, dns.zone
>>> dns.ttl.from_text("\u00b2")
ValueError: invalid literal for int() with base 10: '²'
>>> dns.zone.from_text("@ 3\u00b2 IN A 1.2.3.4\n", origin="example.")
ValueError: invalid literal for int() with base 10: '3²'
>>> dns.zone.from_text("$TTL 1\u00b2\n@ IN A 1.2.3.4\n", origin="example.")
ValueError: invalid literal for int() with base 10: '1²'

After

>>> dns.ttl.from_text("\u00b2")
dns.ttl.BadTTL
>>> dns.zone.from_text("@ 3\u00b2 IN A 1.2.3.4\n", origin="example.")
dns.exception.SyntaxError: ...

Fix

Switch both isdigit() checks to isdecimal().

This is exactly behaviour-preserving for every valid TTL. For a single character, int()-acceptance is identical to str.isdecimal(), and isdecimal() is a strict subset of isdigit():

# int()-acceptance != isdecimal(): 0 chars over the whole Unicode range
# isdecimal-but-not-isdigit chars: 0

So no previously valid TTL is rejected; only the non-decimal "digits" that already crashed are now cleanly reported as BadTTL.

Tests

Added test_non_decimal_unicode_digits, covering both the all-digit fast path and the BIND-style units path ("\u00b2", "1\u00b2", "\u00b2w", "1\u00b2s", "\u1369").

Proven to guard the bug (stash only the source fix, keep the test):

# without the fix:
FAILED tests/test_ttl.py::TTLTestCase::test_non_decimal_unicode_digits
  ValueError: invalid literal for int() with base 10: '²'  (dns/ttl.py:46)
# with the fix:
1 passed

Verification

  • tests/test_ttl.py: 10 passed
  • tests/test_zone.py tests/test_tokenizer.py: 169 passed
  • Full suite: 1281 passed, 118 skipped (the single test_basic_getaddrinfo failure is a pre-existing environment artifact — the local system resolver returns IPv4-mapped IPv6 for dns.google — present on main before this change, unrelated to it).
  • ruff check, ruff format --check, black --check clean on the changed files.

Diff is +17 / −2 across dns/ttl.py, tests/test_ttl.py, and a doc/whatsnew.rst bullet.

from_text() gated its integer parsing on str.isdigit(), then handed the
text to int().  str.isdigit() is True for Unicode "digit" characters whose
category is No (e.g. the superscript "\u00b2" or the Ethiopic "\u1369"),
but int() only accepts decimal digits, so such input escaped the parser's
validation and raised a bare ValueError instead of the documented
dns.ttl.BadTTL.

This was reachable through the public zone-file parser: a resource-record
TTL or a $TTL directive containing such a character crashed
dns.zone.from_text() with a ValueError rather than a clean
dns.exception.SyntaxError.

Switch both isdigit() checks to isdecimal().  For a single character,
int()-acceptance is exactly equivalent to isdecimal(), and isdecimal() is a
strict subset of isdigit(), so every previously valid TTL still parses while
the non-decimal digits are now rejected as BadTTL.

Add a regression test covering the fast (all-digit) path and the BIND-style
units path.
@rthalley rthalley merged commit 7cc53f6 into rthalley:main Jul 3, 2026
11 checks passed
@rthalley

rthalley commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Thanks! I will look at all the other isdigit() calls as they probably should be isdecimal() too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants