Merge lp:~wgrant/launchpad/bug-677270 into lp:launchpad

Proposed by William Grant
Status: Merged
Merged at revision: 17866
Proposed branch: lp:~wgrant/launchpad/bug-677270
Merge into: lp:launchpad
Diff against target: 152 lines (+57/-8)
3 files modified
lib/lp/services/librarian/client.py (+6/-3)
lib/lp/services/librarianserver/db.py (+20/-2)
lib/lp/services/librarianserver/tests/test_web.py (+31/-3)
To merge this branch: bzr merge lp:~wgrant/launchpad/bug-677270
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+279974@code.launchpad.net

Commit message

Canonicalise path encoding before checking a librarian TimeLimitedToken.

Description of the change

Canonicalise path encoding before checking a librarian TimeLimitedToken, and leave tildes unescaped in librarian URLs to appease RFC 3986 and Chromium.

I'm also tempted to whitelist +, as it has no special meaning in the path. Thoughts welcome.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

IRC discussion has convinced me that we should whitelist +; doing so is harmless and would probably make e.g. dget behave more gracefully. Otherwise this looks good to me.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/services/librarian/client.py'
--- lib/lp/services/librarian/client.py 2015-07-08 16:05:11 +0000
+++ lib/lp/services/librarian/client.py 2015-12-10 00:38:11 +0000
@@ -53,9 +53,12 @@
5353
54def url_path_quote(filename):54def url_path_quote(filename):
55 """Quote `filename` for use in a URL."""55 """Quote `filename` for use in a URL."""
56 # XXX RobertCollins 2004-09-21: Perhaps filenames with / in them56 # RFC 3986 says ~ should not be generated escaped, but urllib.quote
57 # should be disallowed?57 # predates it. Additionally, + is safe to use unescaped in paths and is
58 return urllib.quote(filename).replace('/', '%2F')58 # frequently used in Debian versions, so leave it alone.
59 #
60 # This needs to match Library.getAlias' TimeLimitedToken handling.
61 return urllib.quote(filename, safe='/~+')
5962
6063
61def get_libraryfilealias_download_path(aliasID, filename):64def get_libraryfilealias_download_path(aliasID, filename):
6265
=== modified file 'lib/lp/services/librarianserver/db.py'
--- lib/lp/services/librarianserver/db.py 2014-09-02 02:03:37 +0000
+++ lib/lp/services/librarianserver/db.py 2015-12-10 00:38:11 +0000
@@ -9,9 +9,11 @@
9 ]9 ]
1010
11import hashlib11import hashlib
12import urllib
1213
13from storm.expr import (14from storm.expr import (
14 And,15 And,
16 Or,
15 SQL,17 SQL,
16 )18 )
1719
@@ -56,13 +58,29 @@
56 """58 """
57 restricted = self.restricted59 restricted = self.restricted
58 if token and path:60 if token and path:
59 # with a token and a path we may be able to serve restricted files61 # With a token and a path we may be able to serve restricted files
60 # on the public port.62 # on the public port.
63 #
64 # The URL-encoding of the path may have changed somewhere
65 # along the line, so reencode it canonically. LFA.filename
66 # can't contain slashes, so they're safe to leave unencoded.
67 # And urllib.quote erroneously excludes ~ from its safe set,
68 # while RFC 3986 says it should be unescaped and Chromium
69 # forcibly decodes it in any URL that it sees.
70 #
71 # This needs to match url_path_quote.
72 plain_tilde_path = urllib.quote(urllib.unquote(path), safe='/~+')
73 # XXX wgrant 2015-12-09: We used to generate URLs with
74 # escaped tildes, so support those until the tokens are all
75 # expired.
76 encoded_tilde_path = urllib.quote(urllib.unquote(path), safe='/')
61 store = session_store()77 store = session_store()
62 token_found = store.find(TimeLimitedToken,78 token_found = store.find(TimeLimitedToken,
63 SQL("age(created) < interval '1 day'"),79 SQL("age(created) < interval '1 day'"),
64 TimeLimitedToken.token == hashlib.sha256(token).hexdigest(),80 TimeLimitedToken.token == hashlib.sha256(token).hexdigest(),
65 TimeLimitedToken.path == path).is_empty()81 Or(
82 TimeLimitedToken.path == plain_tilde_path,
83 TimeLimitedToken.path == encoded_tilde_path)).is_empty()
66 store.reset()84 store.reset()
67 if token_found:85 if token_found:
68 raise LookupError("Token stale/pruned/path mismatch")86 raise LookupError("Token stale/pruned/path mismatch")
6987
=== modified file 'lib/lp/services/librarianserver/tests/test_web.py'
--- lib/lp/services/librarianserver/tests/test_web.py 2015-10-14 15:22:01 +0000
+++ lib/lp/services/librarianserver/tests/test_web.py 2015-12-10 00:38:11 +0000
@@ -15,6 +15,8 @@
15from lazr.uri import URI15from lazr.uri import URI
16import pytz16import pytz
17from storm.expr import SQL17from storm.expr import SQL
18import testtools
19from testtools.matchers import EndsWith
18import transaction20import transaction
19from zope.component import getUtility21from zope.component import getUtility
2022
@@ -48,7 +50,7 @@
48 return str(parsed.replace(path=parsed.path.replace(old, new)))50 return str(parsed.replace(path=parsed.path.replace(old, new)))
4951
5052
51class LibrarianWebTestCase(unittest.TestCase):53class LibrarianWebTestCase(testtools.TestCase):
52 """Test the librarian's web interface."""54 """Test the librarian's web interface."""
53 layer = LaunchpadFunctionalLayer55 layer = LaunchpadFunctionalLayer
54 dbuser = 'librarian'56 dbuser = 'librarian'
@@ -237,13 +239,13 @@
237 self.failUnlessEqual(239 self.failUnlessEqual(
238 last_modified_header, 'Tue, 30 Jan 2001 13:45:59 GMT')240 last_modified_header, 'Tue, 30 Jan 2001 13:45:59 GMT')
239241
240 def get_restricted_file_and_public_url(self):242 def get_restricted_file_and_public_url(self, filename='sample'):
241 # Use a regular LibrarianClient to ensure we speak to the243 # Use a regular LibrarianClient to ensure we speak to the
242 # nonrestricted port on the librarian which is where secured244 # nonrestricted port on the librarian which is where secured
243 # restricted files are served from.245 # restricted files are served from.
244 client = LibrarianClient()246 client = LibrarianClient()
245 fileAlias = client.addFile(247 fileAlias = client.addFile(
246 'sample', 12, StringIO('a' * 12), contentType='text/plain')248 filename, 12, StringIO('a' * 12), contentType='text/plain')
247 # Note: We're deliberately using the wrong url here: we should be249 # Note: We're deliberately using the wrong url here: we should be
248 # passing secure=True to getURLForAlias, but to use the returned URL250 # passing secure=True to getURLForAlias, but to use the returned URL
249 # we would need a wildcard DNS facility patched into urlopen; instead251 # we would need a wildcard DNS facility patched into urlopen; instead
@@ -333,6 +335,30 @@
333 finally:335 finally:
334 fileObj.close()336 fileObj.close()
335337
338 def test_restricted_with_token_encoding(self):
339 fileAlias, url = self.get_restricted_file_and_public_url('foo~%')
340 self.assertThat(url, EndsWith('/foo~%25'))
341
342 # We have the base url for a restricted file; grant access to it
343 # for a short time.
344 token = TimeLimitedToken.allocate(url)
345
346 # Now we should be able to access the file.
347 fileObj = urlopen(url + "?token=%s" % token)
348 try:
349 self.assertEqual("a" * 12, fileObj.read())
350 finally:
351 fileObj.close()
352
353 # The token is valid even if the filename is encoded differently.
354 mangled_url = url.replace('~', '%7E')
355 self.assertNotEqual(mangled_url, url)
356 fileObj = urlopen(mangled_url + "?token=%s" % token)
357 try:
358 self.assertEqual("a" * 12, fileObj.read())
359 finally:
360 fileObj.close()
361
336 def test_restricted_with_expired_token(self):362 def test_restricted_with_expired_token(self):
337 fileAlias, url = self.get_restricted_file_and_public_url()363 fileAlias, url = self.get_restricted_file_and_public_url()
338 # We have the base url for a restricted file; grant access to it364 # We have the base url for a restricted file; grant access to it
@@ -384,6 +410,7 @@
384 layer = LaunchpadZopelessLayer410 layer = LaunchpadZopelessLayer
385411
386 def setUp(self):412 def setUp(self):
413 super(LibrarianZopelessWebTestCase, self).setUp()
387 switch_dbuser(config.librarian.dbuser)414 switch_dbuser(config.librarian.dbuser)
388415
389 def commit(self):416 def commit(self):
@@ -409,6 +436,7 @@
409 layer = LaunchpadZopelessLayer436 layer = LaunchpadZopelessLayer
410437
411 def setUp(self):438 def setUp(self):
439 super(DeletedContentTestCase, self).setUp()
412 switch_dbuser(config.librarian.dbuser)440 switch_dbuser(config.librarian.dbuser)
413441
414 def test_deletedContentNotFound(self):442 def test_deletedContentNotFound(self):