Skip to content

Commit ce39054

Browse files
committed
fix path traversal
1 parent 6407680 commit ce39054

7 files changed

Lines changed: 46 additions & 9 deletions

File tree

src/rockgarden/output/tags.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
"""Tag index page generation."""
22

3+
import re
34
from pathlib import Path
45

56
from jinja2 import Environment
67

78
from rockgarden.content.models import Page
8-
from rockgarden.urls import get_url
9+
from rockgarden.urls import get_tag_url, get_tags_root_url, get_url
910

1011

1112
def normalize_tag(tag: str) -> str:
1213
"""Normalize a tag to a URL-safe slug.
1314
14-
Strips leading '#' and lowercases. Tags 'Python', '#python', and 'python'
15-
all normalize to 'python'.
15+
Strips leading '#', lowercases, and replaces any character that is not
16+
alphanumeric, hyphen, or underscore with a hyphen. This prevents path
17+
traversal via tags containing '/' or '..'.
18+
19+
Tags 'Python', '#python', and 'python' all normalize to 'python'.
20+
Obsidian nested tags like 'character/pc' normalize to 'character-pc'.
1621
"""
17-
return tag.lstrip("#").lower()
22+
slug = tag.lstrip("#").lower()
23+
slug = re.sub(r"[^a-z0-9_-]", "-", slug)
24+
slug = re.sub(r"-+", "-", slug)
25+
return slug.strip("-")
1826

1927

2028
def collect_tags(pages: list[Page]) -> dict[str, list[Page]]:
@@ -56,7 +64,10 @@ def build_tag_pages(
5664
pages=page_entries,
5765
site=site_config,
5866
)
59-
out_file = output / "tags" / tag_slug / "index.html"
67+
if clean_urls:
68+
out_file = output / "tags" / tag_slug / "index.html"
69+
else:
70+
out_file = output / "tags" / f"{tag_slug}.html"
6071
out_file.parent.mkdir(parents=True, exist_ok=True)
6172
out_file.write_text(html)
6273

src/rockgarden/render/engine.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from rockgarden.config import Config
1111
from rockgarden.content.models import Page
1212
from rockgarden.nav.tree import NavNode
13+
from rockgarden.urls import get_tag_url, get_tags_root_url
1314

1415

1516
def _make_format_datetime(tz_name: str):
@@ -62,6 +63,9 @@ def create_engine(
6263
autoescape=True,
6364
)
6465
env.filters["format_datetime"] = _make_format_datetime(config.dates.timezone)
66+
clean_urls = config.site.clean_urls
67+
env.globals["tag_url"] = lambda slug: get_tag_url(slug, clean_urls)
68+
env.globals["tags_root_url"] = get_tags_root_url(clean_urls)
6569
return env
6670

6771

src/rockgarden/templates/folder_index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
{% for tag in child.tags %}
5252
{% set tag_slug = tag.lstrip('#').lower() %}
5353
{% if site.tag_index %}
54-
<a href="/tags/{{ tag_slug }}/" class="badge badge-sm badge-ghost hover:badge-primary">{{ tag.lstrip('#') }}</a>
54+
<a href="{{ tag_url(tag_slug) }}" class="badge badge-sm badge-ghost hover:badge-primary">{{ tag.lstrip('#') }}</a>
5555
{% else %}
5656
<span class="badge badge-sm badge-ghost">{{ tag.lstrip('#') }}</span>
5757
{% endif %}

src/rockgarden/templates/page.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
{% for tag in tags %}
2929
{% set tag_slug = tag.lstrip('#').lower() %}
3030
{% if site.tag_index %}
31-
<a href="/tags/{{ tag_slug }}/" class="badge badge-sm badge-ghost hover:badge-primary">{{ tag.lstrip('#') }}</a>
31+
<a href="{{ tag_url(tag_slug) }}" class="badge badge-sm badge-ghost hover:badge-primary">{{ tag.lstrip('#') }}</a>
3232
{% else %}
3333
<span class="badge badge-sm badge-ghost">{{ tag.lstrip('#') }}</span>
3434
{% endif %}

src/rockgarden/templates/tag_index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<nav aria-label="Breadcrumb" class="text-sm breadcrumbs mb-4">
99
<ul>
1010
<li><a href="/">Home</a></li>
11-
<li><a href="/tags/">Tags</a></li>
11+
<li><a href="{{ tags_root_url }}">Tags</a></li>
1212
<li>#{{ tag }}</li>
1313
</ul>
1414
</nav>

src/rockgarden/templates/tags_root.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ <h1 class="text-3xl font-bold mb-6">Tags</h1>
1616
{% if tags %}
1717
<div class="flex flex-wrap gap-3">
1818
{% for tag_slug, count in tags.items() %}
19-
<a href="/tags/{{ tag_slug }}/" class="badge badge-lg badge-ghost hover:badge-primary gap-1">
19+
<a href="{{ tag_url(tag_slug) }}" class="badge badge-lg badge-ghost hover:badge-primary gap-1">
2020
#{{ tag_slug }}
2121
<span class="text-base-content/50 text-xs">{{ count }}</span>
2222
</a>

src/rockgarden/urls.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,28 @@ def get_url(slug: str, clean_urls: bool = True) -> str:
7373
return f"/{slug}.html"
7474

7575

76+
def get_tag_url(tag_slug: str, clean_urls: bool = True) -> str:
77+
"""Get URL for a tag index page.
78+
79+
Args:
80+
tag_slug: Normalized tag slug (e.g., "python").
81+
clean_urls: If True, uses trailing slash format.
82+
83+
Returns:
84+
URL path:
85+
- clean_urls=True: "python" → "/tags/python/"
86+
- clean_urls=False: "python" → "/tags/python.html"
87+
"""
88+
if clean_urls:
89+
return f"/tags/{tag_slug}/"
90+
return f"/tags/{tag_slug}.html"
91+
92+
93+
def get_tags_root_url(clean_urls: bool = True) -> str:
94+
"""Get URL for the tags root index page."""
95+
return "/tags/" if clean_urls else "/tags/index.html"
96+
97+
7698
def get_folder_url(folder_path: str, clean_urls: bool = True) -> str:
7799
"""Get URL for a folder.
78100

0 commit comments

Comments
 (0)