fix(self-check): reject CSS asset loading (#205)

* fix(self-check): reject CSS asset loading

* fix(self-check): normalize CSS continuations

---------

Co-authored-by: Manoj Prabhakar Paidiparthy <mpaidiparthy@microsoft.com>
Co-authored-by: Cathryn Lavery <50469282+cathrynlavery@users.noreply.github.com>
This commit is contained in:
manojp99
2026-09-09 21:14:38 -07:00
committed by GitHub
parent 5d59b9b6bd
commit 8bb6fff4ea
2 changed files with 181 additions and 2 deletions
+111
View File
@@ -45,6 +45,12 @@ def main() -> int:
else:
print(f"OK: {label} rejected")
def check_source_pass(label: str, source: str) -> None:
with tempfile.TemporaryDirectory() as scratch:
candidate = Path(scratch) / "candidate.html"
candidate.write_text(source, encoding="utf-8")
check_pass(label, candidate)
check_pass("shipped template", TEMPLATE)
check_pass("shipped animated example", EXAMPLE)
check_pass("shipped static example", STATIC_EXAMPLE)
@@ -62,6 +68,111 @@ def main() -> int:
static.replace("<body>", '<body><img src="https://tracker.example/p.gif">', 1),
"remote reference",
)
check_fail(
"CSS import",
static.replace(
"</style>", '@import "https://tracker.example/theme.css";</style>', 1
),
"CSS @import",
)
check_fail(
"external CSS URL",
static.replace(
"</style>",
".tracked { background: url(https://tracker.example/p.gif); }</style>",
1,
),
"non-fragment CSS url()",
)
check_fail(
"escaped CSS import",
static.replace(
"</style>", '@\\69mport "https://tracker.example/theme.css";</style>', 1
),
"CSS @import",
)
check_fail(
"escaped CSS URL",
static.replace(
"</style>",
".tracked { background: \\75rl(https\\3a //tracker.example/p.gif); }</style>",
1,
),
"non-fragment CSS url()",
)
continuation = chr(92) + "\n"
check_fail(
"CSS continuation URL",
static.replace(
"</style>",
".tracked { background: "
f'\\75rl("https:{continuation}/{continuation}/tracker.example/p.gif"); '
"}</style>",
1,
),
"non-fragment CSS url()",
)
for label, newline in (
("LF", "\n"),
("CRLF", "\r\n"),
("CR", "\r"),
("form feed", "\f"),
):
continuation = chr(92) + newline
normalized = module.normalize_css_escapes(
f'url("https:{continuation}/{continuation}/tracker.example/p.gif")'
)
if normalized != 'url("https://tracker.example/p.gif")':
failures.append(f"CSS {label} continuation was not removed: {normalized!r}")
else:
print(f"OK: CSS {label} continuation normalized")
check_fail(
"CSS image set",
static.replace(
"</style>",
'.tracked { background: image-set("https://tracker.example/p.gif" 1x); }</style>',
1,
),
"CSS image-set()",
)
check_fail(
"inline style URL",
static.replace("<body>", '<body style="background:url(local.png)">', 1),
"non-fragment CSS url()",
)
check_fail(
"SVG presentation URL",
static.replace(
"</svg>",
'<rect fill="url(https://tracker.example/p.svg#paint)"></rect></svg>',
1,
),
"non-fragment CSS url()",
)
check_fail(
"duplicate style URL",
static.replace(
"<body>",
'<body style="background:url(remote.png)" style="background:none">',
1,
),
"non-fragment CSS url()",
)
check_fail(
"CSS string comment bypass",
static.replace(
"</style>",
'.tracked { content: "/*"; background: url(remote.png); --x: "*/"; }</style>',
1,
),
"non-fragment CSS url()",
)
check_source_pass(
"CSS-looking prose",
static.replace(
"<body>", "<body><p>Document @import and url(example) syntax.</p>", 1
),
)
check_fail(
"lookalike fonts host",
static.replace(
+70 -2
View File
@@ -28,7 +28,35 @@ MOTION_TEMPLATE = SKILL_DIR / "assets" / "template-motion.html"
MODES = {"none", "reveal", "step", "loop"}
ACTIONS = {"play", "pause", "replay", "prev", "next"}
ASCII_DECIMAL_RE = re.compile(r"^[0-9]+$")
REFERENCE_ATTRS = {"src", "href", "xlink:href", "poster", "srcset", "action", "formaction"}
CSS_ESCAPE_RE = re.compile(
r"\\(?:([0-9a-fA-F]{1,6})[ \t\r\n\f]?|(.))", re.DOTALL
)
CSS_IMPORT_RE = re.compile(r"@import\b", re.IGNORECASE)
CSS_URL_RE = re.compile(r"url\(\s*([^)]+?)\s*\)", re.IGNORECASE)
CSS_IMAGE_SET_RE = re.compile(r"(?:-webkit-)?image-set\s*\(", re.IGNORECASE)
CSS_REMOTE_RE = re.compile(r"(?:https?:)?//", re.IGNORECASE)
CSS_REFERENCE_ATTRS = {
"style",
"fill",
"stroke",
"filter",
"clip-path",
"mask",
"marker",
"marker-start",
"marker-mid",
"marker-end",
"cursor",
}
REFERENCE_ATTRS = {
"src",
"href",
"xlink:href",
"poster",
"srcset",
"action",
"formaction",
}
class DiagramParser(HTMLParser):
@@ -42,6 +70,7 @@ class DiagramParser(HTMLParser):
self.statuses_in_controls = 0
self.scripts: list[dict[str, object]] = []
self.styles: list[str] = []
self.css_attributes: list[str] = []
self.svgs: list[dict[str, object]] = []
self.unsafe: list[str] = []
self.references: list[tuple[str, str, str]] = []
@@ -57,7 +86,9 @@ class DiagramParser(HTMLParser):
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.casefold()
normalized_attrs = [(key.casefold(), value or "") for key, value in attrs]
data = {key: value for key, value in normalized_attrs}
data: dict[str, str] = {}
for key, value in normalized_attrs:
data.setdefault(key, value)
if tag in {"base", "embed", "object", "iframe"}:
self.unsafe.append(f"<{tag}> is not allowed in a diagram file")
for key, value in normalized_attrs:
@@ -67,6 +98,8 @@ class DiagramParser(HTMLParser):
self.unsafe.append(f"srcdoc attribute on <{tag}>")
if key in REFERENCE_ATTRS:
self.references.append((tag, data.get("rel", ""), value))
if key in CSS_REFERENCE_ATTRS:
self.css_attributes.append(value)
if "data-motion-root" in data:
self.roots.append(data)
if self._motion_root_depth is None:
@@ -197,6 +230,40 @@ def reference_error(tag: str, rel: str, value: str) -> str | None:
return f"remote reference on <{tag}>: {stripped[:80]}"
def normalize_css_escapes(source: str) -> str:
source = source.replace("\r\n", "\n").replace("\r", "\n").replace("\f", "\n")
def replace(match: re.Match[str]) -> str:
if match.group(1) is None:
escaped = match.group(2)
return "" if escaped == "\n" else escaped
codepoint = int(match.group(1), 16)
if codepoint == 0 or codepoint > 0x10FFFF:
return "\N{REPLACEMENT CHARACTER}"
return chr(codepoint)
return CSS_ESCAPE_RE.sub(replace, source)
def check_css_references(parser: DiagramParser, errors: list[str]) -> None:
# Match the repository linter's fail-closed treatment of CSS loader syntax.
source = normalize_css_escapes("\n".join(parser.styles + parser.css_attributes))
found_loader = False
if CSS_IMPORT_RE.search(source):
errors.append("CSS @import is not allowed")
found_loader = True
for match in CSS_URL_RE.finditer(source):
value = match.group(1).strip().strip("'\"").strip()
if not value.startswith("#"):
errors.append("non-fragment CSS url() is not allowed")
found_loader = True
if CSS_IMAGE_SET_RE.search(source):
errors.append("CSS image-set() is not allowed")
found_loader = True
if CSS_REMOTE_RE.search(source) and not found_loader:
errors.append("remote reference in CSS is not allowed")
def canonical_controller() -> str:
if not MOTION_TEMPLATE.is_file():
raise RuntimeError(
@@ -355,6 +422,7 @@ def verify(path: Path) -> list[str]:
parser = parsed_document(source)
errors: list[str] = []
errors.extend(parser.unsafe)
check_css_references(parser, errors)
for tag, rel, value in parser.references:
finding = reference_error(tag, rel, value)
if finding: