mitodl/pylti

Incorrect handling of LTI launch URLs that include query params with percent-encoded chars in decoded value

robertknight opened this issue · 0 comments

We ran into a bug with our LTI app which uses PyLTI where LTI launches would always fail if the launch URL contained a query parameter where the value, after parsing the query string into items, contained percent-encoded characters. The OAuth 1.0 signature base string was being generated incorrectly in this case.

I distilled the relevant part of our code into the following reproduction using PyLTI:

Steps to reproduce:

Run the following code in Python 3:

import time
from urllib.parse import urlencode

import pylti.common


consumers = {"TEST_CONSUMER": {"secret": "TEST_SECRET"}}
params = {
    "oauth_consumer_key": "TEST_CONSUMER",
    "oauth_signature_method": "HMAC-SHA1",
    "oauth_timestamp": int(time.time()),
    "oauth_nonce": "foo",
    "oauth_signature": "DUMMY_SIGNATURE",
}
query = urlencode({"url": "https://en.wikipedia.org/wiki/G%C3%B6reme_National_ark"})
url = f"https://example.com/lti_launch?{query}"
pylti.common.verify_request_common(
    consumers=consumers, url=url, method="POST", params=params, headers={}
)

This will fail with an error similar to:

oauth2.Error: Invalid signature. Expected signature base string: b'POST&https%3A%2F%2Fexample.com%2Flti_launch&oauth_consumer_key%3DTEST_CONSUMER%26oauth_nonce%3Dfoo%26oauth_si
gnature_method%3DHMAC-SHA1%26oauth_timestamp%3D1562586532%26url%3Dhttps%253A%252F%252Fen.wikipedia.org%252Fwiki%252FG%25C3%25B6reme_National_ark

If you extract the part of the message starting at "url%3D", and decode it, you'll get the query string param:

"url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FG%C3%B6reme_National_ark"

Decode this again to get the URL value and you'll get:

"url=https://en.wikipedia.org/wiki/Göreme_National_ark"

Which doesn't match the original "url" param from the query string above. The "ö" character should still be percent-encoded.

If on the other hand the url query param value is passed only in the params:

import time

import pylti.common


consumers = {"TEST_CONSUMER": {"secret": "TEST_SECRET"}}
params = {
    "oauth_consumer_key": "TEST_CONSUMER",
    "oauth_signature_method": "HMAC-SHA1",
    "oauth_timestamp": int(time.time()),
    "oauth_nonce": "foo",
    "oauth_signature": "DUMMY_SIGNATURE",
}
params.update({"url": "https://en.wikipedia.org/wiki/G%C3%B6reme_National_ark"})
url = f"https://example.com/lti_launch"
pylti.common.verify_request_common(
    consumers=consumers, url=url, method="POST", params=params, headers={}
)

Then the error produced is:

oauth2.Error: Invalid signature. Expected signature base string: b'POST&https%3A%2F%2Fexample.com%2Flti_launch&oauth_consumer_key%3DTEST_CONSUMER%26oauth_nonce%3Dfoo%26oauth_si
gnature_method%3DHMAC-SHA1%26oauth_timestamp%3D1562587085%26url%3Dhttps%253A%252F%252Fen.wikipedia.org%252Fwiki%252FG%2525C3%2525B6reme_National_ark'

And if you extract the part from "url%3D" and double-decode it as before, you get the correct output:

"url=https://en.wikipedia.org/wiki/G%C3%B6reme_National_ark"

Related bug:

If a query param appears in both the URL and the "params" dict, the generated signature base string includes the param only once. My reading of the spec, and what oauthlib does, is to include both versions.

Downstream workaround:

Our downstream workaround was to strip the query string from the url argument and pass query params in the params dict instead. See hypothesis/lms#764