Skip to content

Commit 082b049

Browse files
kahneChanghan Wang
andauthored
Harden web rendering and pagination (#115)
Co-authored-by: Changhan Wang <changhan@meta.com>
1 parent 1cba3d8 commit 082b049

9 files changed

Lines changed: 449 additions & 122 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ jobs:
5757
run: bash get_example_data.sh
5858
- run: python -m tests.test_n_gram_based_scorers
5959
- run: python -m tests.test_ipynb
60+
- run: python -m tests.test_web
6061

6162
embeddings:
6263
needs: lint

tests/test_web.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
# Copyright (c) Facebook, Inc. and its affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
import argparse
8+
import io
9+
import json
10+
import os
11+
import tempfile
12+
import unittest
13+
import urllib.parse
14+
import zipfile
15+
16+
from tornado.testing import AsyncHTTPTestCase
17+
18+
from vizseq import server
19+
from vizseq._data import VizSeqDataSources
20+
from vizseq._view.data_view import VizSeqDataPageView
21+
from vizseq._view import mem_cached_data_getters
22+
23+
24+
XSS_PAYLOAD = '</script><script>alert("vizseq-xss")</script>'
25+
26+
27+
def _clear_data_caches():
28+
mem_cached_data_getters._get_src.cache_clear()
29+
mem_cached_data_getters._get_ref.cache_clear()
30+
mem_cached_data_getters._get_tag.cache_clear()
31+
mem_cached_data_getters._get_scores.cache_clear()
32+
mem_cached_data_getters.__get_hypo.cache_clear()
33+
34+
35+
class VizSeqDataPageViewTestCase(unittest.TestCase):
36+
def setUp(self):
37+
lines = ['zero', 'one', 'two', 'three', 'four', 'five']
38+
self.sources = VizSeqDataSources({'source': lines})
39+
self.references = VizSeqDataSources({'reference': lines})
40+
self.hypothesis = VizSeqDataSources({'model': lines})
41+
42+
def test_empty_search_returns_an_empty_page(self):
43+
page = VizSeqDataPageView.get(
44+
self.sources,
45+
self.references,
46+
self.hypothesis,
47+
page_sz=2,
48+
page_no=1,
49+
query='not present',
50+
disable_alignment=True,
51+
)
52+
53+
self.assertEqual(page.cur_idx, [])
54+
self.assertEqual(page.n_samples, 0)
55+
self.assertEqual(page.n_cur_samples, 0)
56+
57+
def test_out_of_range_page_is_clamped_to_the_last_full_page(self):
58+
page = VizSeqDataPageView.get(
59+
self.sources,
60+
self.references,
61+
self.hypothesis,
62+
page_sz=2,
63+
page_no=99,
64+
disable_alignment=True,
65+
)
66+
67+
self.assertEqual(page.cur_idx, [4, 5])
68+
69+
70+
class VizSeqWebTestCase(AsyncHTTPTestCase):
71+
def setUp(self):
72+
self.temp_dir = tempfile.TemporaryDirectory()
73+
self.old_home = os.environ.get('HOME')
74+
os.environ['HOME'] = self.temp_dir.name
75+
self.data_root = os.path.join(self.temp_dir.name, 'data')
76+
self.task_root = os.path.join(self.data_root, 'test_task')
77+
os.makedirs(self.task_root)
78+
79+
sources = ['zero', XSS_PAYLOAD, 'two', 'three', 'four', 'five']
80+
references = ['zero', 'one', 'two', 'three', 'four', 'five']
81+
predictions = ['wrong', 'one', 'wrong', 'three', 'wrong', 'five']
82+
tags = ['safe', XSS_PAYLOAD, 'safe', 'safe', 'safe', 'safe']
83+
self._write_lines('src_0.txt', sources)
84+
self._write_lines('ref_0.txt', references)
85+
self._write_lines('pred_model.txt', predictions)
86+
self._write_lines('tag_0.txt', tags)
87+
88+
server.args = argparse.Namespace(data_root=self.data_root)
89+
_clear_data_caches()
90+
super().setUp()
91+
92+
def tearDown(self):
93+
super().tearDown()
94+
_clear_data_caches()
95+
if self.old_home is None:
96+
os.environ.pop('HOME', None)
97+
else:
98+
os.environ['HOME'] = self.old_home
99+
self.temp_dir.cleanup()
100+
101+
def get_app(self):
102+
return server.make_app()
103+
104+
def _write_lines(self, filename, lines):
105+
with open(os.path.join(self.task_root, filename), 'w') as file:
106+
file.write('\n'.join(lines) + '\n')
107+
108+
def _view_url(self, **params):
109+
query = {'t': 'test_task', 'm': 'model'}
110+
query.update(params)
111+
return '/view?' + urllib.parse.urlencode(query)
112+
113+
@staticmethod
114+
def _multipart_zip(filename, zip_bytes):
115+
boundary = b'vizseq-test-boundary'
116+
body = b'\r\n'.join([
117+
b'--' + boundary,
118+
b'Content-Disposition: form-data; name="file1"; filename="'
119+
+ filename.encode('ascii') + b'"',
120+
b'Content-Type: application/zip',
121+
b'',
122+
zip_bytes,
123+
b'--' + boundary + b'--',
124+
b'',
125+
])
126+
content_type = 'multipart/form-data; boundary=' + boundary.decode('ascii')
127+
return body, content_type
128+
129+
def test_view_escapes_script_payloads_in_html_and_javascript(self):
130+
response = self.fetch(self._view_url(q=XSS_PAYLOAD))
131+
body = response.body.decode('utf-8')
132+
133+
self.assertEqual(response.code, 200)
134+
self.assertNotIn(XSS_PAYLOAD, body)
135+
self.assertIn(r'\u003c/script\u003e', body)
136+
self.assertIn('&lt;/script&gt;', body)
137+
self.assertNotIn('javascript:getGTranslate', body)
138+
139+
def test_empty_search_renders_a_valid_empty_state(self):
140+
response = self.fetch(self._view_url(q='not present'))
141+
142+
self.assertEqual(response.code, 200)
143+
self.assertIn(
144+
b'No examples match the current search.', response.body
145+
)
146+
147+
def test_invalid_pagination_and_sorting_return_400(self):
148+
for params in ({'p_no': 0}, {'p_sz': 101}, {'s': 999}):
149+
with self.subTest(params=params):
150+
response = self.fetch(self._view_url(**params))
151+
self.assertEqual(response.code, 400)
152+
153+
def test_page_data_forwards_metric_sorting_and_returns_json(self):
154+
query = urllib.parse.urlencode({
155+
't': 'test_task',
156+
'm': 'model',
157+
's': 6,
158+
's_metric': 'wer',
159+
})
160+
response = self.fetch('/page_data?' + query)
161+
payload = json.loads(response.body)
162+
163+
self.assertEqual(response.code, 200)
164+
self.assertTrue(
165+
response.headers['Content-Type'].startswith('application/json')
166+
)
167+
self.assertEqual(payload['cur_idx'][:3], [1, 3, 5])
168+
169+
def test_page_tags_follow_paginated_indices(self):
170+
response = self.fetch(self._view_url(p_sz=2, p_no=2))
171+
body = response.body.decode('utf-8')
172+
173+
self.assertEqual(response.code, 200)
174+
self.assertNotIn('badge badge-primary">&lt;/script', body)
175+
176+
def test_upload_rejects_path_traversal_and_removes_temporary_zip(self):
177+
archive = io.BytesIO()
178+
with zipfile.ZipFile(archive, 'w') as zip_file:
179+
zip_file.writestr('../escaped.txt', 'unsafe')
180+
body, content_type = self._multipart_zip('malicious.zip', archive.getvalue())
181+
182+
response = self.fetch(
183+
'/upload',
184+
method='POST',
185+
headers={'Content-Type': content_type},
186+
body=body,
187+
follow_redirects=False,
188+
)
189+
190+
self.assertEqual(response.code, 400)
191+
self.assertFalse(os.path.exists(os.path.join(self.data_root, 'malicious.zip')))
192+
self.assertFalse(os.path.exists(os.path.join(self.temp_dir.name, 'escaped.txt')))
193+
194+
def test_upload_rejects_corrupt_archives_and_missing_files(self):
195+
body, content_type = self._multipart_zip('corrupt.zip', b'not a zip')
196+
corrupt_response = self.fetch(
197+
'/upload',
198+
method='POST',
199+
headers={'Content-Type': content_type},
200+
body=body,
201+
follow_redirects=False,
202+
)
203+
missing_response = self.fetch('/upload', method='POST', body=b'')
204+
205+
self.assertEqual(corrupt_response.code, 400)
206+
self.assertEqual(missing_response.code, 400)
207+
self.assertFalse(os.path.exists(os.path.join(self.data_root, 'corrupt.zip')))
208+
209+
210+
if __name__ == '__main__':
211+
unittest.main()

vizseq/_templates/base.html

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,15 @@
2424

2525
<body>
2626

27-
<script>
28-
function goTo(endpoint) {
29-
window.location.href = location.protocol + '//' + location.hostname + ':' + location.port + '/' + endpoint;
30-
}
31-
</script>
32-
3327
<header>
3428
<nav class="navbar navbar-expand-lg navbar-dark fixed-top" style="background-color:#3b5998">
35-
<a class="navbar-brand" href="javascript:goTo('about');">VizSeq</a>
29+
<a class="navbar-brand" href="/about">VizSeq</a>
3630
<div class="collapse navbar-collapse" id="navbarCollapse">
3731
<ul class="navbar-nav mr-auto">
38-
<li class="nav-item active"><a class="nav-link" href="javascript:goTo('');">Tasks</a></li>
39-
<li class="nav-item active"><a class="nav-link" href="javascript:goTo('upload');">Upload</a></li>
40-
<li class="nav-item active"><a class="nav-link" href="javascript:goTo('config');">Configuration</a></li>
41-
<li class="nav-item active"><a class="nav-link" href="javascript:goTo('about');">About</a></li>
32+
<li class="nav-item active"><a class="nav-link" href="/">Tasks</a></li>
33+
<li class="nav-item active"><a class="nav-link" href="/upload">Upload</a></li>
34+
<li class="nav-item active"><a class="nav-link" href="/config">Configuration</a></li>
35+
<li class="nav-item active"><a class="nav-link" href="/about">About</a></li>
4236
</ul>
4337
</div>
4438
</nav>

vizseq/_templates/macros.html

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,28 @@
1919

2020
<script>
2121
function updateStatsTable(stats) {
22+
function appendCell(row, tagName, value, scope) {
23+
let cell = document.createElement(tagName);
24+
if (scope) { cell.setAttribute('scope', scope); }
25+
cell.textContent = value;
26+
row.appendChild(cell);
27+
}
2228
enum_src_names_and_types.forEach(function (e) {
2329
let trNode = document.createElement('tr');
24-
trNode.innerHTML +=
25-
`<th scope="row">Source ${e[1]}</th>
26-
<td>${e[2]}</td>
27-
<td>${stats['n_examples']}</td>
28-
<td>${stats['n_src_tokens'][e[1]]}</td>
29-
<td>${stats['n_src_chars'][e[1]]}</td>`
30-
;
30+
appendCell(trNode, 'th', 'Source ' + e[1], 'row');
31+
appendCell(trNode, 'td', e[2]);
32+
appendCell(trNode, 'td', stats['n_examples']);
33+
appendCell(trNode, 'td', stats['n_src_tokens'][e[1]]);
34+
appendCell(trNode, 'td', stats['n_src_chars'][e[1]]);
3135
document.getElementById('statsTable').appendChild(trNode);
3236
});
3337
enum_ref_names.forEach(function (e) {
3438
let trNode = document.createElement('tr');
35-
trNode.innerHTML +=
36-
`<th scope="row">Reference ${e[1]}</th>
37-
<td>Text</td>
38-
<td>${stats['n_examples']}</td>
39-
<td>${stats['n_ref_tokens'][e[1]]}</td>
40-
<td>${stats['n_ref_chars'][e[1]]}</td>`
41-
;
39+
appendCell(trNode, 'th', 'Reference ' + e[1], 'row');
40+
appendCell(trNode, 'td', 'Text');
41+
appendCell(trNode, 'td', stats['n_examples']);
42+
appendCell(trNode, 'td', stats['n_ref_tokens'][e[1]]);
43+
appendCell(trNode, 'td', stats['n_ref_chars'][e[1]]);
4244
document.getElementById('statsTable').appendChild(trNode);
4345
});
4446
}
@@ -52,15 +54,15 @@
5254
{% if group1|length > 0 %}
5355
{% for i in group1 %}
5456
<li class="page-item">
55-
<a class="page-link" href="javascript:deriveURL(urlArgs, &quot;p_no&quot;, &quot;{{ i }}&quot;);">{{ i }}</a>
57+
<a class="page-link" href="#" onclick="deriveURL(urlArgs, 'p_no', '{{ i }}'); return false;">{{ i }}</a>
5658
</li>
5759
{% endfor %}
5860
<li class="page-item">&emsp;&emsp;</li>
5961
{% endif %}
6062

6163
{% for i in group2 %}
6264
<li class="page-item">
63-
<a class="page-link" href="javascript:deriveURL(urlArgs, &quot;p_no&quot;, &quot;{{ i }}&quot;);">{{ i }}</a>
65+
<a class="page-link" href="#" onclick="deriveURL(urlArgs, 'p_no', '{{ i }}'); return false;">{{ i }}</a>
6466
</li>
6567
{% endfor %}
6668

@@ -70,15 +72,15 @@
7072

7173
{% for i in group3 %}
7274
<li class="page-item">
73-
<a class="page-link" href="javascript:deriveURL(urlArgs, &quot;p_no&quot;, &quot;{{ i }}&quot;);">{{ i }}</a>
75+
<a class="page-link" href="#" onclick="deriveURL(urlArgs, 'p_no', '{{ i }}'); return false;">{{ i }}</a>
7476
</li>
7577
{% endfor %}
7678

7779
{% if group4|length > 0 %}
7880
<li class="page-item">&emsp;&emsp;</li>
7981
{% for i in group4 %}
8082
<li class="page-item">
81-
<a class="page-link" href="javascript:deriveURL(urlArgs, &quot;p_no&quot;, &quot;{{ i }}&quot;);">{{ i }}</a>
83+
<a class="page-link" href="#" onclick="deriveURL(urlArgs, 'p_no', '{{ i }}'); return false;">{{ i }}</a>
8284
</li>
8385
{% endfor %}
8486
{% endif %}

0 commit comments

Comments
 (0)