mirror of
https://github.com/cathrynlavery/diagram-design.git
synced 2026-09-14 16:31:34 +08:00
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:
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user