feat(okf): resource-URL https allowlist reject-gate (T3, TDD, +10)
This commit is contained in:
parent
ec121f3259
commit
f9a89938b4
2 changed files with 92 additions and 0 deletions
|
|
@ -31,9 +31,11 @@ __all__ = [
|
|||
"parse_frontmatter",
|
||||
"scan_concept",
|
||||
"validate_concept_path",
|
||||
"validate_resource_url",
|
||||
"OKFError",
|
||||
"OKFFrontmatterError",
|
||||
"OKFPathError",
|
||||
"OKFResourceError",
|
||||
]
|
||||
|
||||
_FENCE = "---"
|
||||
|
|
@ -58,6 +60,13 @@ class OKFPathError(OKFError):
|
|||
"""A concept path is unsafe (traversal, absolute, or reserved-name shadow)."""
|
||||
|
||||
|
||||
class OKFResourceError(OKFError):
|
||||
"""A ``resource`` URL is not on the https allowlist."""
|
||||
|
||||
|
||||
_URL_SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.\-]*):")
|
||||
|
||||
|
||||
# `index.md` (directory listing) and `log.md` (update history) are reserved by
|
||||
# the OKF spec and MUST NOT name concept documents — at any directory level.
|
||||
_RESERVED_BASENAMES = frozenset({"index.md", "log.md"})
|
||||
|
|
@ -159,6 +168,33 @@ def validate_concept_path(path):
|
|||
return path[: -len(".md")]
|
||||
|
||||
|
||||
def validate_resource_url(url):
|
||||
"""Validate a concept's ``resource`` URL against the https allowlist (T3).
|
||||
|
||||
The OKF format places no constraint on the ``resource`` scheme (verified
|
||||
against SPEC.md), so this default-deny allowlist is the only gate: it
|
||||
**rejects** anything that is not ``https`` — ``http``, ``data:``,
|
||||
``javascript:``, ``file:``, ``blob:``, ``ftp:`` and schemeless/relative
|
||||
strings — *before commit*. This is reject, not defang: ``neutralize`` renders
|
||||
dangerous schemes inert for human audit; this refuses to persist them at all.
|
||||
|
||||
Returns ``url`` unchanged on success; raises :class:`OKFResourceError`
|
||||
otherwise.
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
raise OKFResourceError("empty or non-string resource URL: %r" % (url,))
|
||||
stripped = url.strip()
|
||||
if " " in stripped or any(ord(c) < 0x20 for c in stripped):
|
||||
raise OKFResourceError("resource URL contains whitespace/control chars: %r" % url)
|
||||
match = _URL_SCHEME_RE.match(stripped)
|
||||
scheme = match.group(1).lower() if match else None
|
||||
if scheme != "https":
|
||||
raise OKFResourceError(
|
||||
"resource URL must use the https scheme (got %r): %r" % (scheme, url)
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
def _parse_flat(fm_lines):
|
||||
result = {}
|
||||
i = 0
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ from llm_ingestion_guard.okf import (
|
|||
parse_frontmatter,
|
||||
scan_concept,
|
||||
validate_concept_path,
|
||||
validate_resource_url,
|
||||
OKFFrontmatterError,
|
||||
OKFPathError,
|
||||
OKFResourceError,
|
||||
)
|
||||
from llm_ingestion_guard.report import Report
|
||||
|
||||
|
|
@ -200,3 +202,57 @@ def test_validate_concept_path_rejects_backslash():
|
|||
def test_validate_concept_path_rejects_non_md():
|
||||
with pytest.raises(OKFPathError):
|
||||
validate_concept_path("tables/users.txt")
|
||||
|
||||
|
||||
# --- T3: resource-URL https allowlist reject-gate ----------------------------
|
||||
# OKF imposes NO scheme constraint on `resource` (verified against SPEC.md), so
|
||||
# this default-deny allowlist is the only gate: accept https, reject all else
|
||||
# BEFORE commit — reject, not defang (that is neutralize's job, for human audit).
|
||||
|
||||
def test_validate_resource_url_accepts_https():
|
||||
assert validate_resource_url("https://example.com/asset") == "https://example.com/asset"
|
||||
|
||||
|
||||
def test_validate_resource_url_accepts_https_case_insensitive_scheme():
|
||||
assert validate_resource_url("HTTPS://example.com") == "HTTPS://example.com"
|
||||
|
||||
|
||||
def test_validate_resource_url_rejects_http():
|
||||
with pytest.raises(OKFResourceError):
|
||||
validate_resource_url("http://example.com/asset")
|
||||
|
||||
|
||||
def test_validate_resource_url_rejects_data():
|
||||
with pytest.raises(OKFResourceError):
|
||||
validate_resource_url("data:text/html,<script>alert(1)</script>")
|
||||
|
||||
|
||||
def test_validate_resource_url_rejects_javascript():
|
||||
with pytest.raises(OKFResourceError):
|
||||
validate_resource_url("javascript:alert(1)")
|
||||
|
||||
|
||||
def test_validate_resource_url_rejects_file():
|
||||
with pytest.raises(OKFResourceError):
|
||||
validate_resource_url("file:///etc/passwd")
|
||||
|
||||
|
||||
def test_validate_resource_url_rejects_ftp():
|
||||
with pytest.raises(OKFResourceError):
|
||||
validate_resource_url("ftp://host/x")
|
||||
|
||||
|
||||
def test_validate_resource_url_rejects_schemeless():
|
||||
with pytest.raises(OKFResourceError):
|
||||
validate_resource_url("example.com/asset")
|
||||
|
||||
|
||||
def test_validate_resource_url_rejects_empty():
|
||||
with pytest.raises(OKFResourceError):
|
||||
validate_resource_url("")
|
||||
|
||||
|
||||
def test_validate_resource_url_rejects_embedded_whitespace():
|
||||
# a space-split URL can smuggle a second target past a naive consumer parser
|
||||
with pytest.raises(OKFResourceError):
|
||||
validate_resource_url("https://good.example/x javascript:alert(1)")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue