Skip to content

Commit 667c59c

Browse files
committed
fix: bugs.
1 parent 3fafa73 commit 667c59c

7 files changed

Lines changed: 175 additions & 31 deletions

File tree

backend/app/routers/acl.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,12 @@ async def put_page_acl(slug: str, body: AclPutBody, user=Depends(get_current_use
206206
VALUES (?, ?, ?, ?)""",
207207
(page["id"], r.principal_type, r.principal_id, r.permission),
208208
)
209+
# Metadata intentionally omits principal details: activity_log is readable
210+
# by any authenticated user (see pending finding #5), and leaking the
211+
# principal/permission list would reveal organizational ACL structure.
209212
await log_activity(
210213
db, user["id"], "acl_updated", "page", page["id"],
211-
{"slug": slug, "rows": [r.model_dump() for r in body.rows]},
214+
{"slug": slug, "row_count": len(body.rows)},
212215
)
213216
await db.commit()
214217
invalidate_readable_cache()

backend/app/routers/export.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import base64
2+
import html as _html
23
import io
34
import mimetypes
45
import re
@@ -283,9 +284,12 @@ async def export_page(
283284
if await resolve_page_permission(db, user, page["id"]) == "none":
284285
raise HTTPException(status_code=404, detail="Page not found")
285286
html_content = _inline_media_srcs(md_to_simple_html(page["content_md"]))
287+
# Escape title/slug: they're rendered into <title>, <h1>, and a meta
288+
# breadcrumb as raw text, so a page title containing e.g. `<script>` would
289+
# execute when the exported HTML is opened locally (file:// context).
286290
full_html = HTML_TEMPLATE.format(
287-
title=page["title"],
288-
slug=page["slug"],
291+
title=_html.escape(page["title"]),
292+
slug=_html.escape(page["slug"]),
289293
content=html_content,
290294
)
291295

@@ -331,13 +335,20 @@ async def export_site(
331335
for p in pages:
332336
page = dict(p)
333337
html_content = _inline_media_srcs(md_to_simple_html(page["content_md"]))
338+
safe_title = _html.escape(page["title"])
339+
safe_slug = _html.escape(page["slug"])
334340
full_html = HTML_TEMPLATE.format(
335-
title=page["title"],
336-
slug=page["slug"],
341+
title=safe_title,
342+
slug=safe_slug,
337343
content=html_content,
338344
)
339345
zf.writestr(f"{page['slug']}.html", full_html)
340-
page_links.append(f'<li><a href="{page["slug"]}.html">{page["title"]}</a></li>')
346+
# Both href and link text need escaping — slug may contain chars
347+
# that break the attribute, title may contain HTML.
348+
page_links.append(
349+
f'<li><a href="{urllib.parse.quote(page["slug"], safe="")}.html">'
350+
f'{safe_title}</a></li>'
351+
)
341352

342353
# Generate index
343354
index_html = SITE_INDEX_TEMPLATE.format(page_list="\n".join(page_links))

backend/app/routers/templates.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import aiosqlite
22
from fastapi import APIRouter, HTTPException, Depends
33
from app.schemas import TemplateCreate, TemplateUpdate, TemplateResponse
4-
from app.auth import get_current_user
4+
from app.auth import get_current_user, require_admin
55
from app.database import get_db
66

77
router = APIRouter(prefix="/api/templates", tags=["templates"])
@@ -15,7 +15,7 @@ async def list_templates(user=Depends(get_current_user)):
1515

1616

1717
@router.post("", response_model=TemplateResponse, status_code=201)
18-
async def create_template(body: TemplateCreate, user=Depends(get_current_user)):
18+
async def create_template(body: TemplateCreate, user=Depends(require_admin)):
1919
db = await get_db()
2020
try:
2121
cursor = await db.execute(
@@ -33,7 +33,7 @@ async def create_template(body: TemplateCreate, user=Depends(get_current_user)):
3333

3434
@router.put("/{template_id}", response_model=TemplateResponse)
3535
async def update_template(
36-
template_id: int, body: TemplateUpdate, user=Depends(get_current_user)
36+
template_id: int, body: TemplateUpdate, user=Depends(require_admin)
3737
):
3838
db = await get_db()
3939
rows = await db.execute_fetchall(
@@ -62,7 +62,7 @@ async def update_template(
6262

6363

6464
@router.delete("/{template_id}")
65-
async def delete_template(template_id: int, user=Depends(get_current_user)):
65+
async def delete_template(template_id: int, user=Depends(require_admin)):
6666
db = await get_db()
6767
rows = await db.execute_fetchall(
6868
"SELECT id FROM templates WHERE id = ?", (template_id,)

backend/tests/test_acl.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -837,6 +837,53 @@ async def test_acl_my_permission_endpoint():
837837
assert b.status_code == 404
838838

839839

840+
@pytest.mark.asyncio
841+
async def test_acl_updated_activity_log_omits_principal_rows():
842+
"""PUT /acl writes to activity_log, which is readable by any authenticated
843+
user. The metadata must NOT contain principal_type/principal_id/permission
844+
details — that would leak organizational ACL structure across the wiki.
845+
"""
846+
import json as _json
847+
db = await get_db()
848+
admin = await _get_or_create_user(db, "acl_log_admin", "admin")
849+
target = await _get_or_create_user(db, "acl_log_target", "editor")
850+
page = await _make_page(db, "acl-log-page")
851+
852+
async with _token_client(admin) as client:
853+
r = await client.put(
854+
f"/api/pages/acl-log-page/acl",
855+
json={
856+
"rows": [
857+
{
858+
"principal_type": "user",
859+
"principal_id": target["id"],
860+
"permission": "write",
861+
}
862+
]
863+
},
864+
)
865+
assert r.status_code == 200
866+
867+
rows = await db.execute_fetchall(
868+
"""SELECT metadata FROM activity_log
869+
WHERE action = 'acl_updated' AND target_id = ?
870+
ORDER BY id DESC LIMIT 1""",
871+
(page,),
872+
)
873+
assert rows, "acl_updated activity entry missing"
874+
meta = _json.loads(rows[0]["metadata"])
875+
# Fine to expose slug + row count — both are bounded.
876+
assert meta.get("slug") == "acl-log-page"
877+
assert meta.get("row_count") == 1
878+
# Must NOT contain principal details.
879+
assert "rows" not in meta
880+
serialized = _json.dumps(meta)
881+
assert "principal_type" not in serialized
882+
assert "principal_id" not in serialized
883+
assert "permission" not in serialized
884+
assert str(target["id"]) not in serialized
885+
886+
840887
@pytest.mark.asyncio
841888
async def test_users_search_endpoint():
842889
db = await get_db()

backend/tests/test_export.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,43 @@ async def test_export_double_backtick_doesnt_desync_inline_code(auth_client):
149149
assert "</code>.env" not in body
150150

151151

152+
@pytest.mark.asyncio
153+
async def test_export_escapes_title_and_slug(auth_client):
154+
"""Title/slug are injected raw into <title>, <h1>, <a href>, meta — any
155+
HTML in them must be escaped so opening the downloaded HTML doesn't run
156+
attacker-controlled script.
157+
"""
158+
await auth_client.post("/api/pages", json={
159+
"title": "<script>alert('xss-title')</script>",
160+
"content_md": "body",
161+
"slug": "xss-target",
162+
})
163+
response = await auth_client.get("/api/export/page/xss-target")
164+
assert response.status_code == 200
165+
body = response.text
166+
# Escaped form should appear; raw <script> tag for the title should not.
167+
assert "&lt;script&gt;alert(&#x27;xss-title&#x27;)&lt;/script&gt;" in body
168+
assert "<script>alert('xss-title')</script>" not in body
169+
170+
171+
@pytest.mark.asyncio
172+
async def test_export_site_escapes_title_in_index(admin_client):
173+
"""Site export's index.html lists page titles as link text — must escape
174+
both the title (link text) and slug (href)."""
175+
await admin_client.post("/api/pages", json={
176+
"title": "<img src=x onerror=alert(1)>",
177+
"content_md": "body",
178+
"slug": "xss-in-index",
179+
})
180+
import zipfile, io
181+
response = await admin_client.get("/api/export/site")
182+
assert response.status_code == 200
183+
with zipfile.ZipFile(io.BytesIO(response.content)) as zf:
184+
index = zf.read("index.html").decode()
185+
assert "&lt;img src=x onerror=alert(1)&gt;" in index
186+
assert "<img src=x onerror=alert(1)>" not in index
187+
188+
152189
@pytest.mark.asyncio
153190
async def test_export_site_requires_admin(auth_client):
154191
# Non-admins should be blocked — site exports would otherwise silently

backend/tests/test_pages_advanced.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,14 @@ async def test_page_move(auth_client):
6464
assert response.json()["sort_order"] == 5
6565

6666
@pytest.mark.asyncio
67-
async def test_create_page_with_template(auth_client):
68-
# 1. Create template
69-
res_tmpl = await auth_client.post("/api/templates", json={
67+
async def test_create_page_with_template(auth_client, admin_client):
68+
# Templates are admin-managed; any authenticated user can apply one.
69+
res_tmpl = await admin_client.post("/api/templates", json={
7070
"name": "Tmpl",
7171
"content_md": "Template Content"
7272
})
7373
tmpl_id = res_tmpl.json()["id"]
7474

75-
# 2. Create page using template
7675
response = await auth_client.post("/api/pages", json={
7776
"title": "Tmpl Page",
7877
"template_id": tmpl_id

backend/tests/test_templates.py

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import pytest
22

33
@pytest.mark.asyncio
4-
async def test_templates(auth_client):
4+
async def test_templates(admin_client):
55
# Create template
6-
response = await auth_client.post("/api/templates", json={
6+
response = await admin_client.post("/api/templates", json={
77
"name": "Test Template",
88
"description": "Desc",
99
"content_md": "# Tmpl Content"
@@ -12,54 +12,101 @@ async def test_templates(auth_client):
1212
tmpl_id = response.json()["id"]
1313

1414
# List templates
15-
response = await auth_client.get("/api/templates")
15+
response = await admin_client.get("/api/templates")
1616
assert response.status_code == 200
1717
assert any(t["id"] == tmpl_id for t in response.json())
1818

1919
# Update template
20-
response = await auth_client.put(f"/api/templates/{tmpl_id}", json={
20+
response = await admin_client.put(f"/api/templates/{tmpl_id}", json={
2121
"name": "Updated Tmpl"
2222
})
2323
assert response.status_code == 200
2424
assert response.json()["name"] == "Updated Tmpl"
2525

2626
# Delete template
27-
response = await auth_client.delete(f"/api/templates/{tmpl_id}")
27+
response = await admin_client.delete(f"/api/templates/{tmpl_id}")
2828
assert response.status_code == 200
2929
assert response.json() == {"ok": True}
3030

3131

3232
@pytest.mark.asyncio
33-
async def test_create_duplicate_template_name_returns_409(auth_client):
33+
async def test_create_duplicate_template_name_returns_409(admin_client):
3434
payload = {"name": "Unique409Name", "description": "d", "content_md": "# A"}
35-
r1 = await auth_client.post("/api/templates", json=payload)
35+
r1 = await admin_client.post("/api/templates", json=payload)
3636
assert r1.status_code == 201
3737

38-
r2 = await auth_client.post("/api/templates", json=payload)
38+
r2 = await admin_client.post("/api/templates", json=payload)
3939
assert r2.status_code == 409
4040

4141
# cleanup
42-
await auth_client.delete(f"/api/templates/{r1.json()['id']}")
42+
await admin_client.delete(f"/api/templates/{r1.json()['id']}")
4343

4444

4545
@pytest.mark.asyncio
46-
async def test_update_template_to_duplicate_name_returns_409(auth_client):
47-
t1 = (await auth_client.post("/api/templates", json={
46+
async def test_update_template_to_duplicate_name_returns_409(admin_client):
47+
t1 = (await admin_client.post("/api/templates", json={
4848
"name": "Tmpl409A", "content_md": "a"
4949
})).json()
50-
t2 = (await auth_client.post("/api/templates", json={
50+
t2 = (await admin_client.post("/api/templates", json={
5151
"name": "Tmpl409B", "content_md": "b"
5252
})).json()
5353

54-
r = await auth_client.put(f"/api/templates/{t2['id']}", json={"name": "Tmpl409A"})
54+
r = await admin_client.put(f"/api/templates/{t2['id']}", json={"name": "Tmpl409A"})
5555
assert r.status_code == 409
5656

5757
# cleanup
58-
await auth_client.delete(f"/api/templates/{t1['id']}")
59-
await auth_client.delete(f"/api/templates/{t2['id']}")
58+
await admin_client.delete(f"/api/templates/{t1['id']}")
59+
await admin_client.delete(f"/api/templates/{t2['id']}")
6060

6161

6262
@pytest.mark.asyncio
63-
async def test_delete_nonexistent_template_returns_404(auth_client):
64-
r = await auth_client.delete("/api/templates/999999")
63+
async def test_delete_nonexistent_template_returns_404(admin_client):
64+
r = await admin_client.delete("/api/templates/999999")
6565
assert r.status_code == 404
66+
67+
68+
@pytest.mark.asyncio
69+
async def test_non_admin_cannot_create_template(auth_client):
70+
r = await auth_client.post("/api/templates", json={
71+
"name": "Forbidden", "content_md": "x"
72+
})
73+
assert r.status_code == 403
74+
75+
76+
@pytest.mark.asyncio
77+
async def test_non_admin_cannot_update_template(auth_client, admin_client):
78+
created = (await admin_client.post("/api/templates", json={
79+
"name": "OwnedByAdmin", "content_md": "x"
80+
})).json()
81+
try:
82+
r = await auth_client.put(
83+
f"/api/templates/{created['id']}", json={"name": "Hijacked"}
84+
)
85+
assert r.status_code == 403
86+
finally:
87+
await admin_client.delete(f"/api/templates/{created['id']}")
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_non_admin_cannot_delete_template(auth_client, admin_client):
92+
created = (await admin_client.post("/api/templates", json={
93+
"name": "OwnedByAdmin2", "content_md": "x"
94+
})).json()
95+
try:
96+
r = await auth_client.delete(f"/api/templates/{created['id']}")
97+
assert r.status_code == 403
98+
finally:
99+
await admin_client.delete(f"/api/templates/{created['id']}")
100+
101+
102+
@pytest.mark.asyncio
103+
async def test_non_admin_can_still_list_templates(auth_client, admin_client):
104+
created = (await admin_client.post("/api/templates", json={
105+
"name": "Listable", "content_md": "x"
106+
})).json()
107+
try:
108+
r = await auth_client.get("/api/templates")
109+
assert r.status_code == 200
110+
assert any(t["id"] == created["id"] for t in r.json())
111+
finally:
112+
await admin_client.delete(f"/api/templates/{created['id']}")

0 commit comments

Comments
 (0)