diff --git a/_includes/action_bar.html b/_includes/action_bar.html index 751a07c1089..09f629753d4 100644 --- a/_includes/action_bar.html +++ b/_includes/action_bar.html @@ -10,9 +10,9 @@ role="button" tabindex="0" data-conf-id="{{ conf_id }}" - data-conf-name="{{ include.conf_name }}" + data-conf-name="{{ include.conf_name | escape }}" data-conf-cfp="{{ include.conf_cfp }}" - data-conf-place="{{ include.conf_place }}" + data-conf-place="{{ include.conf_place | escape }}" aria-label="Conference actions"> diff --git a/_includes/calendar.js b/_includes/calendar.js index b84620792a8..f720dd9d529 100644 --- a/_includes/calendar.js +++ b/_includes/calendar.js @@ -124,15 +124,16 @@ function load_conference_list() { {% assign extended = "(extended)" %} {% else %} {% assign cfp = conf.cfp %} + {% assign extended = "" %} {% endif %} {% capture conf_date %}{%- translate_file dates/pretty_dates.html start=conf.start end=conf.end -%}{% endcapture %} // add deadlines in red conf_list_all.push({ id: "{{conf.conference | slugify: "latin"}}-{{conf.year}}-deadline", abbreviation: "{{conf.conference | slugify: "latin"}}-{{conf.year}}", - name: "{{conf.conference}} {{conf.year}} CfP {{extended}}", + name: {{ conf.conference | append: " " | append: conf.year | append: " CfP " | append: extended | jsonify }}, color: "red", - location: "{{conf.place}}", + location: {{ conf.place | jsonify }}, date: "{{conf_date | strip}}", subject: "{{conf.sub}}", startDate: Date.parse("{{cfp}}"), @@ -142,9 +143,9 @@ function load_conference_list() { conf_list_all.push({ id: "{{conf.conference | slugify: "latin"}}-{{conf.year}}-deadline", abbreviation: "{{conf.conference | slugify: "latin"}}-{{conf.year}}", - name: "{{conf.conference}} {{conf.year}} Workshop Deadline", + name: {{ conf.conference | append: " " | append: conf.year | append: " Workshop Deadline" | jsonify }}, color: "red", - location: "{{conf.place}}", + location: {{ conf.place | jsonify }}, date: "{{ conf_date | strip }}", subject: "{{conf.sub}}", startDate: Date.parse("{{conf.workshop_deadline}}"), @@ -155,9 +156,9 @@ function load_conference_list() { conf_list_all.push({ id: "{{conf.conference | slugify: "latin"}}-{{conf.year}}-deadline", abbreviation: "{{conf.conference | slugify: "latin"}}-{{conf.year}}", - name: "{{conf.conference}} {{conf.year}} Tutorial Deadline", + name: {{ conf.conference | append: " " | append: conf.year | append: " Tutorial Deadline" | jsonify }}, color: "red", - location: "{{conf.place}}", + location: {{ conf.place | jsonify }}, date: "{{ conf_date | strip }}", subject: "{{conf.sub}}", startDate: Date.parse("{{cfp}}"), @@ -177,9 +178,9 @@ function load_conference_list() { conf_list_all.push({ id: "{{conf.conference | slugify: "latin"}}-{{conf.year}}-conference", abbreviation: "{{conf.conference | slugify: "latin"}}-{{conf.year}}", - name: "{{conf.conference}} {{conf.year}}", + name: {{ conf.conference | append: " " | append: conf.year | jsonify }}, color: color, - location: "{{conf.place}}", + location: {{ conf.place | jsonify }}, date: "{{ conf_date | strip }}", subject: "{{conf.sub}}", startDate: Date.parse("{{conf.start}}"), diff --git a/_includes/conference_detail_actions.html b/_includes/conference_detail_actions.html index 08a616cf77d..81a7bfafb80 100644 --- a/_includes/conference_detail_actions.html +++ b/_includes/conference_detail_actions.html @@ -5,17 +5,17 @@
diff --git a/_includes/index_conf_title_row.html b/_includes/index_conf_title_row.html index 0400e4b1d99..b3ee53cfe3c 100644 --- a/_includes/index_conf_title_row.html +++ b/_includes/index_conf_title_row.html @@ -21,10 +21,10 @@ {% if conf.mastodon %} Mastodon {% elsif conf.twitter %} - Twitter + Twitter {% endif %} {% if conf.bluesky %} - + {% endif %} @@ -36,7 +36,7 @@ {{cfp_deadline}} {% else %} - {{cfp_deadline | date: "%b %e, %Y"}} + {{cfp_deadline | date: "%b %e, %Y"}} diff --git a/_layouts/conference.html b/_layouts/conference.html index 50959ba31df..30943b6e097 100644 --- a/_layouts/conference.html +++ b/_layouts/conference.html @@ -10,9 +10,9 @@ {% include head.html %} - - - + + + @@ -62,7 +62,7 @@

{% if confs[0].twitter %}
Twitter icon - @{{confs[0].twitter}} + @{{confs[0].twitter}}
{% endif %} {% if confs[0].mastodon %} @@ -74,7 +74,7 @@

{% if confs[0].bluesky %}
- Bluesky + Bluesky
{% endif %} {% if confs[0].youtube %} @@ -91,9 +91,9 @@

{% elsif conf.items[0].twitter %} - Link to Conference Twitter + Link to Conference Twitter ​ {% endif %} @@ -82,7 +82,7 @@ {% if conf.items[idx].place == "Online" %} {{conf.items[idx].place}} {% else %} - {{conf.items[idx].place}} + {{conf.items[idx].place}} {% endif %}
diff --git a/index.html b/index.html index 88e2c76c891..359e5b26c24 100644 --- a/index.html +++ b/index.html @@ -10,9 +10,9 @@
{% t titles.visit_archive %}]*\bsrc=)[^>]*>(.*?)]*>", re.DOTALL | re.IGNORECASE) +LIQUID = re.compile(r"\{%.*?%\}|\{\{.*?\}\}", re.DOTALL) +OUTPUT_TAG = re.compile(r"\{\{-?(.*?)-?\}\}", re.DOTALL) +INCLUDE = re.compile(r"\{%-?\s*include(?:_cached)?\s+(\S+)(.*?)-?%\}", re.DOTALL) +INCLUDE_PARAMETER = re.compile(r"(\w+)\s*=\s*([\w.\[\]]+)") +VARIABLE = re.compile(r"\{%-?\s*(?:assign\s+(\w+)\s*=|for\s+(\w+)\s+in)(.*?)-?%\}", re.DOTALL) + + +def template_files(): + """List the templates Jekyll renders with Liquid.""" + files = [ROOT / "index.html"] + for directory in TEMPLATE_DIRS: + files.extend(path for path in (ROOT / directory).rglob("*") if path.suffix in {".html", ".js", ".md"}) + return sorted(path for path in files if path.is_file()) + + +def include_name(path): + """Return the name an include tag uses for the file, or None outside _includes.""" + if "_includes" not in path.parts: + return None + return "/".join(path.parts[path.parts.index("_includes") + 1 :]) + + +def field_pattern(fields, variables=()): + """Match a reference to one of the fields, or to a variable that holds one.""" + alternatives = [r"\.(?:" + "|".join(sorted(fields)) + r")\b"] + if variables: + alternatives.append(r"(? in {% if a > b %} is not read as the end of a tag. + markup = LIQUID.sub(lambda match: " " * len(match.group(0)), html) + for tag in OUTPUT_TAG.finditer(html): + if markup.rfind("<", 0, tag.start()) > markup.rfind(">", 0, tag.start()): + yield tag + + +def filters_after_field(expression, pattern): + """Return the filters applied after the last free-text field in a Liquid expression, or None if it has none.""" + fields = list(pattern.finditer(expression)) + if not fields: + return None + return [part.split(":")[0].strip() for part in expression[fields[-1].end() :].split("|")[1:]] + + +def location(path, text, offset, tag): + """Format where a Liquid output tag sits in a template.""" + line = text.count("\n", 0, offset) + 1 + return f"{path.relative_to(ROOT) if path.is_relative_to(ROOT) else path}:{line}: {tag}" + + +def violations(path, text, script_parameters=None, attribute_parameters=None): + """Return one message per Liquid output tag that can break the script or HTML tag around it.""" + found = [] + pattern = file_pattern(path, text, FREE_TEXT_FIELDS, script_parameters or {}) + for offset, source in script_regions(path, text): + for tag in OUTPUT_TAG.finditer(source): + where = location(path, text, offset + tag.start(), tag.group(0)) + filters = filters_after_field(tag.group(1), pattern) + if filters is not None and (not filters or filters[-1] not in SCRIPT_FILTERS): + found.append(f"{where} (free text needs '| jsonify')") + elif tag.group(1).split("|")[-1].strip() == "jsonify": + before = source[tag.start() - 1 : tag.start()] + after = source[tag.end() : tag.end() + 1] + if before in {"'", '"', "`"} or after in {"'", '"', "`"}: + found.append(f"{where} (jsonify emits its own quotes)") + + pattern = file_pattern(path, text, ATTRIBUTE_FIELDS, attribute_parameters or {}) + for tag in attribute_tags(path, text): + filters = filters_after_field(tag.group(1), pattern) + if filters is not None and not ATTRIBUTE_FILTERS.intersection(filters): + where = location(path, text, tag.start(), tag.group(0)) + found.append(f"{where} (free text in an HTML tag needs '| escape' or '| url_encode')") + return found + + +@pytest.mark.parametrize( + ("snippet", "expected"), + [ + ("", 1), + ('', 1), + ("", 1), + ("", 1), + ("", 1), + ("", 0), + ("", 0), + ("", 0), + ("", 0), + ('
', 1), + ('', 1), + ('', 1), + ('', 1), + ('
', 1), + ('{% for place in conf.extra_places %}{{ place }}{% endfor %}', 1), + ('{% assign name = conf.conference | strip %}
', 1), + ('{% assign id = conf.conference | slugify %}', 0), + ('
', 0), + ('', 0), + ('', 0), + ('{{ conf.conference }} {{ conf.year }}', 0), + ("{% if a > b %}

{{ conf.place }}

{% endif %}", 0), + ], +) +def test_checker_flags_unescaped_free_text(tmp_path, snippet, expected): + """The checker flags free text in scripts and HTML tags unless a matching filter escapes it.""" + path = tmp_path / "page.html" + + assert len(violations(path, f"

{{{{ conf.place }}}}

\n{snippet}\n")) == expected + + +def test_checker_follows_include_parameters(tmp_path): + """A field passed into an include under another name is still free text inside the include.""" + caller = (tmp_path / "index.html", "{% include bar.html conf_name=conf.conference conf_id=conf_id %}") + include = tmp_path / "_includes" / "bar.html" + text = '
' + + assert len(violations(include, text, attribute_parameters=include_parameters([caller], ATTRIBUTE_FIELDS))) == 1 + + +def test_checker_reads_templates(): + """The checker finds the jsonify'd conference data the site already injects.""" + head = ROOT / "_includes" / "head.html" + tags = [ + tag.group(1) + for _, source in script_regions(head, head.read_text(encoding="utf-8")) + for tag in OUTPUT_TAG.finditer(source) + ] + + assert any("conf.place" in tag and "jsonify" in tag for tag in tags) + + +def test_checker_reads_attributes(): + """The checker finds the conference names the front page puts into data attributes.""" + index = ROOT / "index.html" + tags = [tag.group(1) for tag in attribute_tags(index, index.read_text(encoding="utf-8"))] + + assert any("conf.conference" in tag for tag in tags) + + +def test_free_text_reaches_templates_escaped(): + """Conference text in scripts goes through jsonify, and in HTML tags through escape or url_encode.""" + templates = [(path, path.read_text(encoding="utf-8")) for path in template_files()] + script_parameters = include_parameters(templates, FREE_TEXT_FIELDS) + attribute_parameters = include_parameters(templates, ATTRIBUTE_FIELDS) + found = [ + message + for path, text in templates + for message in violations(path, text, script_parameters, attribute_parameters) + ] + + assert not found, "Unescaped Liquid output:\n" + "\n".join(found)