From 565cd63efaa61d96ce03159a2dd28e0b9ea53c4c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:53:52 +0000 Subject: [PATCH 1/3] Escape conference text in inline JavaScript with jsonify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Liquid pastes values verbatim, so `place: '{{conf.place}}'` rendered as `place: 'Abidjan, Côte d'Ivoire'` and the front page's per-conference script failed to parse. The curly apostrophe in #512 fixed the name but not the place, so the front page and the PyCon Côte d'Ivoire page still threw a SyntaxError. Every conference name, place and link that reaches JavaScript now goes through jsonify, the way head.html and the JSON-LD block already do. tests/test_template_escaping.py fails when a free-text conference field lands in a script without jsonify or slugify, or when a jsonify'd value is wrapped in quotes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YSanwpb5BXzbhLxMFyz9yS --- _includes/calendar.js | 16 ++--- _layouts/conference.html | 6 +- index.html | 6 +- tests/test_template_escaping.py | 124 ++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 tests/test_template_escaping.py diff --git a/_includes/calendar.js b/_includes/calendar.js index b84620792a8..2cab3f9eadd 100644 --- a/_includes/calendar.js +++ b/_includes/calendar.js @@ -130,9 +130,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}} 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 +142,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 +155,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 +177,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/_layouts/conference.html b/_layouts/conference.html index 50959ba31df..f64a5fb4cf8 100644 --- a/_layouts/conference.html +++ b/_layouts/conference.html @@ -347,10 +347,10 @@

a.k.a. {{page.alt_name}} {{page.year}}

const calendarType = $(this).data('calendar'); // Get conference data from page - const confName = '{{ page.conference }}'; + const confName = {{ page.conference | jsonify }}; const confYear = '{{ page.year }}'; const cfpDate = '{{ page.cfp_ext | default: page.cfp }}'; - const confPlace = '{{ page.place }}'; + const confPlace = {{ page.place | jsonify }}; // Format the date for calendar const eventDate = new Date(cfpDate); @@ -417,7 +417,7 @@

a.k.a. {{page.alt_name}} {{page.year}}

// Initialize button states on page load const confId = '{{ page.conference | slugify: "latin" }}-{{ page.year }}'; - const seriesName = '{{ page.conference }}'; + const seriesName = {{ page.conference | jsonify }}; if (window.confManager) { // Update Follow Series button state diff --git a/index.html b/index.html index 88e2c76c891..a66b1fe9635 100644 --- a/index.html +++ b/index.html @@ -120,11 +120,11 @@

{% t titles.visit_archive %}]*\bsrc=)[^>]*>(.*?)", re.DOTALL | re.IGNORECASE) +OUTPUT_TAG = re.compile(r"\{\{-?(.*?)-?\}\}", re.DOTALL) +FIELD_REF = re.compile(r"\.(" + "|".join(sorted(FREE_TEXT_FIELDS)) + r")\b") + + +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 script_regions(path, text): + """Yield (offset, source) for each stretch of a template that is JavaScript or JSON.""" + if path.suffix == ".js": + yield 0, text + return + for match in SCRIPT.finditer(text): + yield match.start(1), match.group(1) + + +def violations(path, text): + """Return one message per Liquid output tag that can break the script around it.""" + found = [] + for offset, source in script_regions(path, text): + for tag in OUTPUT_TAG.finditer(source): + expression = tag.group(1) + filters = [part.split(":")[0].strip() for part in expression.split("|")[1:]] + last_filter = filters[-1] if filters else None + line = text.count("\n", 0, offset + tag.start()) + 1 + where = f"{path.relative_to(ROOT) if path.is_relative_to(ROOT) else path}:{line}: {tag.group(0)}" + + if FIELD_REF.search(expression) and last_filter not in SAFE_FILTERS: + found.append(f"{where} (free text needs '| jsonify')") + elif last_filter == "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)") + return found + + +@pytest.mark.parametrize( + ("snippet", "expected"), + [ + ("var p = '{{ conf.place }}';", 1), + ('var n = "{{conf.conference}} {{conf.year}}";', 1), + ("var t = '{{ page.conference | append: page.year }}';", 1), + ("var l = '{{ conf.link }}';", 1), + ("var p = '{{ conf.place | jsonify }}';", 1), + ("var p = {{ conf.place | jsonify }};", 0), + ("var t = {{ page.conference | append: ' ' | append: page.year | jsonify }};", 0), + ("var id = '{{ conf.conference | slugify: \"latin\" }}-{{ conf.year }}';", 0), + ("var d = Date.parse('{{ conf.start }}'); var tz = '{{ conf.timezone }}';", 0), + ], +) +def test_checker_flags_unescaped_free_text(tmp_path, snippet, expected): + """The checker flags free text in JavaScript unless jsonify or slugify formats it.""" + path = tmp_path / "page.html" + text = f"

{{{{ conf.place }}}}

\n\n" + + assert len(violations(path, text)) == expected + + +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_free_text_reaches_javascript_escaped(): + """Conference names, places, links and notes in scripts go through jsonify.""" + found = [message for path in template_files() for message in violations(path, path.read_text(encoding="utf-8"))] + + assert not found, "Unescaped Liquid output in JavaScript:\n" + "\n".join(found) From 730439f30c272367c114cfd32b2ada07493b34fb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:56:27 +0000 Subject: [PATCH 2/3] Match script end tags with whitespace or attributes in the template test CodeQL (py/bad-tag-filter) flagged that `` misses ``. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YSanwpb5BXzbhLxMFyz9yS --- tests/test_template_escaping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_template_escaping.py b/tests/test_template_escaping.py index 3fba94905e1..ba4b83e3ef0 100644 --- a/tests/test_template_escaping.py +++ b/tests/test_template_escaping.py @@ -40,7 +40,7 @@ # Filters whose output is safe in JavaScript: a full string literal, or [a-z0-9-]. SAFE_FILTERS = {"jsonify", "slugify"} -SCRIPT = re.compile(r"]*\bsrc=)[^>]*>(.*?)", re.DOTALL | re.IGNORECASE) +SCRIPT = re.compile(r"]*\bsrc=)[^>]*>(.*?)]*>", re.DOTALL | re.IGNORECASE) OUTPUT_TAG = re.compile(r"\{\{-?(.*?)-?\}\}", re.DOTALL) FIELD_REF = re.compile(r"\.(" + "|".join(sorted(FREE_TEXT_FIELDS)) + r")\b") From babd3f26bb6d9897b002dd2cd27a5200d4046f85 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:35:18 +0000 Subject: [PATCH 3/3] Escape conference text in HTML attributes and reset the extended label Free text in a double-quoted attribute ended the value at the first ", so data-conf-name, data-location, aria-label, meta content and the share and map links now go through escape, or url_encode where the value is part of a URL. The share links replace url_encode's + with %20, which mailto: needs and the other targets accept. URL fields stay as they are: the schema validates them and pydantic percent-encodes quotes in them. The front page's action bar got the name and place through include parameters, and the extra places through a for loop, so the template test now follows include parameters and variables set with assign or for. calendar.js never reset `extended`, so every conference after the first extended CfP was labelled "(extended)" too: 66 labels for 16 extended CfPs. It now resets the way calendar-deadlines.ics already does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YSanwpb5BXzbhLxMFyz9yS --- _includes/action_bar.html | 4 +- _includes/calendar.js | 1 + _includes/conference_detail_actions.html | 12 +- _includes/index_conf_date_place.html | 4 +- _includes/index_conf_title_row.html | 6 +- _layouts/conference.html | 26 +-- _layouts/summary.html | 10 +- _pages/series.html | 4 +- index.html | 4 +- tests/test_template_escaping.py | 191 +++++++++++++++++++---- 10 files changed, 197 insertions(+), 65 deletions(-) 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 2cab3f9eadd..f720dd9d529 100644 --- a/_includes/calendar.js +++ b/_includes/calendar.js @@ -124,6 +124,7 @@ 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 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 f64a5fb4cf8..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 %} {% endif %} {% if confs[0].mastodon %} @@ -74,7 +74,7 @@

{% if confs[0].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 a66b1fe9635..359e5b26c24 100644 --- a/index.html +++ b/index.html @@ -10,9 +10,9 @@
]*\bsrc=)[^>]*>(.*?)]*>", re.DOTALL | re.IGNORECASE) +LIQUID = re.compile(r"\{%.*?%\}|\{\{.*?\}\}", re.DOTALL) OUTPUT_TAG = re.compile(r"\{\{-?(.*?)-?\}\}", re.DOTALL) -FIELD_REF = re.compile(r"\.(" + "|".join(sorted(FREE_TEXT_FIELDS)) + r")\b") +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(): @@ -53,6 +77,47 @@ def template_files(): 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): - expression = tag.group(1) - filters = [part.split(":")[0].strip() for part in expression.split("|")[1:]] - last_filter = filters[-1] if filters else None - line = text.count("\n", 0, offset + tag.start()) + 1 - where = f"{path.relative_to(ROOT) if path.is_relative_to(ROOT) else path}:{line}: {tag.group(0)}" - - if FIELD_REF.search(expression) and last_filter not in SAFE_FILTERS: + 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 last_filter == "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"), [ - ("var p = '{{ conf.place }}';", 1), - ('var n = "{{conf.conference}} {{conf.year}}";', 1), - ("var t = '{{ page.conference | append: page.year }}';", 1), - ("var l = '{{ conf.link }}';", 1), - ("var p = '{{ conf.place | jsonify }}';", 1), - ("var p = {{ conf.place | jsonify }};", 0), - ("var t = {{ page.conference | append: ' ' | append: page.year | jsonify }};", 0), - ("var id = '{{ conf.conference | slugify: \"latin\" }}-{{ conf.year }}';", 0), - ("var d = Date.parse('{{ conf.start }}'); var tz = '{{ conf.timezone }}';", 0), + ("", 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 JavaScript unless jsonify or slugify formats it.""" + """The checker flags free text in scripts and HTML tags unless a matching filter escapes it.""" path = tmp_path / "page.html" - text = f"

{{{{ conf.place }}}}

\n\n" - assert len(violations(path, text)) == expected + 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(): @@ -117,8 +233,23 @@ def test_checker_reads_templates(): assert any("conf.place" in tag and "jsonify" in tag for tag in tags) -def test_free_text_reaches_javascript_escaped(): - """Conference names, places, links and notes in scripts go through jsonify.""" - found = [message for path in template_files() for message in violations(path, path.read_text(encoding="utf-8"))] +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 in JavaScript:\n" + "\n".join(found) + assert not found, "Unescaped Liquid output:\n" + "\n".join(found)