hermes: reject regex literals in polkit grants

A regex literal embedding a quote desynced the string tokenizer and
swallowed an unconditional Result.YES, so the grant read as unscoped and
slipped past the literal-identity check. Fail closed on any slash outside
a string or comment; neither regex nor division belongs in an
identity-scoped grant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jenkins 2026-08-17 20:37:30 -03:00
parent 5020e49057
commit 6e9e8c6ab5
2 changed files with 63 additions and 0 deletions

View File

@ -85,6 +85,13 @@ def _split_strings(text: str) -> tuple[str, list[str]]:
code.append(" ") code.append(" ")
index = closing + 2 index = closing + 2
continue continue
if character == "/":
# Any remaining slash is a regex literal or a division operator.
# A regex literal may embed a quote character, which would desync
# the string scanner and swallow an unconditional grant. Neither
# regex nor division has a place in an identity-scoped grant, so
# both fail closed before they can hide a Result.YES.
raise HardeningError("polkit rule uses a regex or division operator")
code.append(character) code.append(character)
index += 1 index += 1
return "".join(code), strings return "".join(code), strings

View File

@ -124,6 +124,62 @@ def test_unprovable_rule_constructs_fail_closed(text: str, match: str):
_rules(module, text) _rules(module, text)
def test_regex_literal_tokenizer_desync_grant_is_rejected():
"""A regex literal embedding a quote must not open a phantom string.
Without regex awareness the two /'/ literals fool the string scanner into
swallowing the unconditional Result.YES between their quotes; the grant
then reads as unscoped and would slip past every later check.
"""
module = _load()
exploit = (
"polkit.addRule(function(action, subject) { "
"var a = /'/; "
'if (action.id == "org.freedesktop.policykit.exec") '
"{ return polkit.Result.YES; } "
"var b = /'/; });\n"
)
with pytest.raises(module.HardeningError, match="regex or division"):
_rules(module, exploit)
@pytest.mark.parametrize(
"snippet",
[
"var a = /'/;",
'var a = /"/;',
"var a = /abc/;",
"var a = /[/'\"]/;",
"var a = /x\\/y/;",
"var a = subject.user / 2;",
"// a comment / with a slash then\nvar a = /'/;",
'/* block */ var a = /"/;',
"return polkit.Result.YES / 1;",
],
)
def test_every_slash_form_outside_a_comment_fails_closed(snippet: str):
module = _load()
text = f"polkit.addRule(function(action, subject) {{ {snippet} }});\n"
with pytest.raises(module.HardeningError, match="regex or division"):
_rules(module, text)
def test_slashes_confined_to_strings_and_comments_stay_legal():
module = _load()
# Slashes inside string literals and // or /* */ comments are inert.
_rules(
module,
"// path-like /usr/bin comment\n"
"/* another /etc/ comment */\n"
"polkit.addRule(function(action, subject) {\n"
' if (action.id == "org.freedesktop.systemd1.manage-units" &&\n'
' subject.user == "systemd-network") {\n'
" return polkit.Result.YES;\n"
" }\n"
"});\n",
)
def test_string_and_comment_handling_keeps_literal_rules_auditable(): def test_string_and_comment_handling_keeps_literal_rules_auditable():
module = _load() module = _load()
code, strings = module._split_strings( code, strings = module._split_strings(