Skip to content

Commit 62a5336

Browse files
committed
fix: prevent path traversal in attachment paths and DataTable src
The DataTable embedding passed its `src` option straight through as an attachment filename, allowing a crafted value such as `../../settings.cfg` to read files outside the wiki repository. Guard the Attachment constructor so the resolved path must stay within the storage repository, and reject `..` components early in the DataTable embedding.
1 parent 3a55602 commit 62a5336

3 files changed

Lines changed: 104 additions & 0 deletions

File tree

otterwiki/renderer_embeddings.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,15 @@ def _csv_to_html_table(self, args: EmbeddingArgs) -> str:
186186
if not src:
187187
return ''
188188

189+
# Reject path traversal early with a clear message. Attachment paths
190+
# are always relative to a page's attachment directory, so a `..`
191+
# component can only be an attempt to escape the repository.
192+
if '..' in src.replace('\\', '/').split('/'):
193+
raise ValueError(
194+
f'datatable: invalid src "{src}", path traversal is not'
195+
' allowed.'
196+
)
197+
189198
delimiter = args.options.get(
190199
'delimiter',
191200
args.options.get('sep', args.options.get('separator', ';')),

otterwiki/wiki.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1353,6 +1353,22 @@ def __init__(self, pagepath, filename, revision=None):
13531353
)
13541354
self.filepath = os.path.join(self.directory, filename)
13551355
self.abspath = os.path.join(storage.path, self.filepath)
1356+
# Guard against path traversal: the resolved attachment path must
1357+
# stay within the storage repository. Without this a crafted filename
1358+
# (or pagepath) such as "../../settings.cfg" would allow reading files
1359+
# outside the wiki repository, e.g. via the DataTable embedding's
1360+
# `src` option.
1361+
storage_root = os.path.realpath(storage.path)
1362+
resolved_abspath = os.path.realpath(self.abspath)
1363+
if (
1364+
os.path.commonpath([storage_root, resolved_abspath])
1365+
!= storage_root
1366+
):
1367+
raise StorageError(
1368+
"Invalid attachment path "
1369+
f"'{os.path.join(pagepath, filename)}': path traversal "
1370+
"outside the repository is not allowed."
1371+
)
13561372
self.mimetype = guess_mimetype(self.filepath)
13571373
try:
13581374
self.metadata = storage.metadata(

tests/test_embeddings.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env python3
22
# vim: set et ts=8 sts=4 sw=4 ai:
33

4+
import os
45
import pytest
56
from otterwiki.renderer import render
67
from bs4 import BeautifulSoup
@@ -890,6 +891,84 @@ def test_datatable_csv_absolute_src(create_app):
890891
assert "City" in html
891892

892893

894+
def _write_secret_outside_repo(create_app):
895+
"""Create a secret file in the parent directory of the repository and
896+
return (path, content). Attachments live in `<repo>/<page>/`, so a
897+
`../../` traversal from there lands in the repository's parent."""
898+
secret_content = "SECRET_KEY = super-secret-value-12345\n"
899+
secret_path = os.path.join(
900+
os.path.dirname(create_app.storage.path.rstrip(os.sep)),
901+
"secret.cfg",
902+
)
903+
with open(secret_path, "w") as f:
904+
f.write(secret_content)
905+
return secret_path, secret_content
906+
907+
908+
def test_datatable_csv_path_traversal_relative_blocked(create_app):
909+
"""A relative src with `..` must not read files outside the repository."""
910+
secret_path, secret_content = _write_secret_outside_repo(create_app)
911+
try:
912+
author = ("Test Author", "test@example.com")
913+
# a genuine attachment so the page's attachment directory exists
914+
create_app.storage.store(
915+
"csvtrav.md",
916+
content="# Traversal\n{{datatable\n|src=../../secret.cfg\n"
917+
"|header=false\n}}\n",
918+
author=author,
919+
message="traversal page",
920+
)
921+
create_app.storage.store(
922+
"csvtrav/data.csv",
923+
content="a;b\n1;2\n",
924+
author=author,
925+
message="add csv",
926+
)
927+
client = create_app.test_client()
928+
response = client.get("/Csvtrav/view")
929+
assert response.status_code == 200
930+
html = response.data.decode()
931+
# the secret must not leak into the rendered page
932+
assert "super-secret-value-12345" not in html
933+
# and the embedding reports a clear error instead
934+
assert "traversal" in html.lower()
935+
finally:
936+
os.remove(secret_path)
937+
938+
939+
def test_datatable_csv_path_traversal_absolute_blocked(create_app):
940+
"""An absolute src with `..` must not escape the repository either."""
941+
secret_path, secret_content = _write_secret_outside_repo(create_app)
942+
try:
943+
author = ("Test Author", "test@example.com")
944+
create_app.storage.store(
945+
"csvtravabs.md",
946+
content="# Traversal\n{{datatable\n"
947+
"|src=/csvtravabs/../../../secret.cfg\n|header=false\n}}\n",
948+
author=author,
949+
message="traversal abs page",
950+
)
951+
client = create_app.test_client()
952+
response = client.get("/Csvtravabs/view")
953+
assert response.status_code == 200
954+
html = response.data.decode()
955+
assert "super-secret-value-12345" not in html
956+
assert "traversal" in html.lower()
957+
finally:
958+
os.remove(secret_path)
959+
960+
961+
def test_attachment_rejects_path_traversal(create_app):
962+
"""The Attachment constructor guards against traversal for every caller,
963+
not just the DataTable embedding."""
964+
from otterwiki.gitstorage import StorageError
965+
from otterwiki.wiki import Attachment
966+
967+
with create_app.app_context():
968+
with pytest.raises(StorageError):
969+
Attachment("somepage", "../../secret.cfg")
970+
971+
893972
def test_attachmentlist(create_app):
894973
author = ("Test Author", "test@example.com")
895974
create_app.storage.store(

0 commit comments

Comments
 (0)