Skip to content

fix: makes url path normalization case sensitive #565

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased]

## [0.29.0] - 2025-03-03
### Breaking changes
- Makes URL path normalization case sensitive
- Updates `normalise_url_path_or_throw_error` to be case sensitive
- URL paths will not be converted to lower-case, and will be kept as-is.

### Changes
- Adds option to disable `tldextract` HTTP calls by setting `SUPERTOKENS_TLDEXTRACT_DISABLE_HTTP=1`

### Infrastructure
- Upgrades `pip` and `setuptools` in CI publish job
- Also upgrades `poetry` and it's dependency - `clikit`
- Migrates unit tests to use a containerized core
- Updates `Makefile` to use a Docker `compose` setup step
- Migrates unit tests from CircleCI to Github Actions
- Adds lint/format checks to Github Actions


## [0.28.1] - 2025-02-26
- Pins `httpx` and `respx` to current major versions (<1.0.0)
- Removes `respx` dependency from `fastapi` install
Expand Down
27 changes: 15 additions & 12 deletions supertokens_python/normalised_url_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,32 +45,35 @@ def is_a_recipe_path(self) -> bool:


def normalise_url_path_or_throw_error(input_str: str) -> str:
input_str = input_str.strip().lower()
input_str = input_str.strip()
input_str_lower = input_str.lower()

try:
if not input_str.startswith("http://") and not input_str.startswith("https://"):
if not input_str_lower.startswith(("http://", "https://")):
raise Exception("converting to proper URL")

url_obj = urlparse(input_str)
input_str = url_obj.path
if input_str.endswith("/"):
return input_str[:-1]
return input_str
url_path = url_obj.path

if url_path.endswith("/"):
return url_path[:-1]

return url_path
except Exception:
pass

if (
(domain_given(input_str) or input_str.startswith("localhost"))
and not input_str.startswith("http://")
and not input_str.startswith("https://")
):
domain_given(input_str_lower) or input_str_lower.startswith("localhost")
) and not input_str_lower.startswith(("http://", "https://")):
input_str = "http://" + input_str
return normalise_url_path_or_throw_error(input_str)

if not input_str.startswith("/"):
input_str = "/" + input_str

try:
urlparse("http://example.com" + input_str)
return normalise_url_path_or_throw_error("http://example.com" + input_str)
urlparse(f"http://example.com{input_str}")
return normalise_url_path_or_throw_error(f"http://example.com{input_str}")
except Exception:
raise_general_exception("Please provide a valid URL path")

Expand Down
164 changes: 64 additions & 100 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from typing import Any, Dict, Optional
from unittest.mock import MagicMock

import pytest
Expand All @@ -24,8 +23,9 @@
from supertokens_python.recipe.session import SessionRecipe
from supertokens_python.recipe.session.asyncio import create_new_session
from supertokens_python.types import RecipeUserId
from typing_extensions import Any, Dict, Optional

from tests.utils import get_new_core_app_url, reset
from tests.utils import get_new_core_app_url, outputs, reset


# Tests do not rely on the core.
Expand All @@ -35,109 +35,73 @@ def st_config() -> SupertokensConfig:
return SupertokensConfig(get_new_core_app_url())


def testing_URL_path_normalisation():
@mark.parametrize(
("input", "expectation"),
[
("exists?email=john.doe%40gmail.com", outputs("/exists")),
(
"/auth/email/exists?email=john.doe%40gmail.com",
outputs("/auth/email/exists"),
),
("http://api.example.com", outputs("")),
("https://api.example.com", outputs("")),
("http://api.example.com?hello=1", outputs("")),
("http://api.example.com/hello", outputs("/hello")),
("http://api.example.com/HellO", outputs("/HellO")),
("http://api.example.com/", outputs("")),
("http://api.example.com:8080", outputs("")),
("api.example.com/", outputs("")),
("api.example.com#random", outputs("")),
(".example.com", outputs("")),
("api.example.com/?hello=1&bye=2", outputs("")),
("exists", outputs("/exists")),
("eXiStS", outputs("/eXiStS")),
("/exists", outputs("/exists")),
("/eXiStS", outputs("/eXiStS")),
("/exists?email=john.doe%40gmail.com", outputs("/exists")),
("http://api.example.com/one/two", outputs("/one/two")),
("http://1.2.3.4/one/two", outputs("/one/two")),
("1.2.3.4/one/two", outputs("/one/two")),
("https://api.example.com/one/two/", outputs("/one/two")),
("http://api.example.com/one/two?hello=1", outputs("/one/two")),
("http://api.example.com/hello/", outputs("/hello")),
("http://api.example.com/one/two/", outputs("/one/two")),
("http://api.example.com/one/two#random2", outputs("/one/two")),
("api.example.com/one/two", outputs("/one/two")),
(".example.com/one/two", outputs("/one/two")),
("api.example.com/one/two?hello=1&bye=2", outputs("/one/two")),
("/one/two", outputs("/one/two")),
("one/two", outputs("/one/two")),
("one/two/", outputs("/one/two")),
("/one", outputs("/one")),
("one", outputs("/one")),
("one/", outputs("/one")),
("/one/two/", outputs("/one/two")),
("/one/two?hello=1", outputs("/one/two")),
("one/two?hello=1", outputs("/one/two")),
("/one/two/#randm,", outputs("/one/two")),
("one/two#random", outputs("/one/two")),
("localhost:4000/one/two", outputs("/one/two")),
("127.0.0.1:4000/one/two", outputs("/one/two")),
("127.0.0.1/one/two", outputs("/one/two")),
("https://127.0.0.1:80/one/two", outputs("/one/two")),
("/", outputs("")),
("", outputs("")),
("/.netlify/functions/api", outputs("/.netlify/functions/api")),
("/netlify/.functions/api", outputs("/netlify/.functions/api")),
("app.example.com/.netlify/functions/api", outputs("/.netlify/functions/api")),
("app.example.com/netlify/.functions/api", outputs("/netlify/.functions/api")),
("/app.example.com", outputs("/app.example.com")),
],
)
def testing_URL_path_normalisation(input: str, expectation: Any) -> None:
def normalise_url_path_or_throw_error(
input: str,
): # pylint: disable=redefined-builtin
return NormalisedURLPath(input).get_as_string_dangerous()

assert (
normalise_url_path_or_throw_error("exists?email=john.doe%40gmail.com")
== "/exists"
)
assert (
normalise_url_path_or_throw_error(
"/auth/email/exists?email=john.doe%40gmail.com"
)
== "/auth/email/exists"
)
assert normalise_url_path_or_throw_error("exists") == "/exists"
assert normalise_url_path_or_throw_error("/exists") == "/exists"
assert (
normalise_url_path_or_throw_error("/exists?email=john.doe%40gmail.com")
== "/exists"
)
assert normalise_url_path_or_throw_error("http://api.example.com") == ""
assert normalise_url_path_or_throw_error("https://api.example.com") == ""
assert normalise_url_path_or_throw_error("http://api.example.com?hello=1") == ""
assert normalise_url_path_or_throw_error("http://api.example.com/hello") == "/hello"
assert normalise_url_path_or_throw_error("http://api.example.com/") == ""
assert normalise_url_path_or_throw_error("http://api.example.com:8080") == ""
assert normalise_url_path_or_throw_error("api.example.com/") == ""
assert normalise_url_path_or_throw_error("api.example.com#random") == ""
assert normalise_url_path_or_throw_error(".example.com") == ""
assert normalise_url_path_or_throw_error("api.example.com/?hello=1&bye=2") == ""

assert (
normalise_url_path_or_throw_error("http://api.example.com/one/two")
== "/one/two"
)
assert normalise_url_path_or_throw_error("http://1.2.3.4/one/two") == "/one/two"
assert normalise_url_path_or_throw_error("1.2.3.4/one/two") == "/one/two"
assert (
normalise_url_path_or_throw_error("https://api.example.com/one/two/")
== "/one/two"
)
assert (
normalise_url_path_or_throw_error("http://api.example.com/one/two?hello=1")
== "/one/two"
)
assert (
normalise_url_path_or_throw_error("http://api.example.com/hello/") == "/hello"
)
assert (
normalise_url_path_or_throw_error("http://api.example.com/one/two/")
== "/one/two"
)
assert (
normalise_url_path_or_throw_error("http://api.example.com/one/two#random2")
== "/one/two"
)
assert normalise_url_path_or_throw_error("api.example.com/one/two") == "/one/two"
assert normalise_url_path_or_throw_error(".example.com/one/two") == "/one/two"
assert (
normalise_url_path_or_throw_error("api.example.com/one/two?hello=1&bye=2")
== "/one/two"
)

assert normalise_url_path_or_throw_error("/one/two") == "/one/two"
assert normalise_url_path_or_throw_error("one/two") == "/one/two"
assert normalise_url_path_or_throw_error("one/two/") == "/one/two"
assert normalise_url_path_or_throw_error("/one") == "/one"
assert normalise_url_path_or_throw_error("one") == "/one"
assert normalise_url_path_or_throw_error("one/") == "/one"
assert normalise_url_path_or_throw_error("/one/two/") == "/one/two"
assert normalise_url_path_or_throw_error("/one/two?hello=1") == "/one/two"
assert normalise_url_path_or_throw_error("one/two?hello=1") == "/one/two"
assert normalise_url_path_or_throw_error("/one/two/#randm,") == "/one/two"
assert normalise_url_path_or_throw_error("one/two#random") == "/one/two"

assert normalise_url_path_or_throw_error("localhost:4000/one/two") == "/one/two"
assert normalise_url_path_or_throw_error("127.0.0.1:4000/one/two") == "/one/two"
assert normalise_url_path_or_throw_error("127.0.0.1/one/two") == "/one/two"
assert (
normalise_url_path_or_throw_error("https://127.0.0.1:80/one/two") == "/one/two"
)
assert normalise_url_path_or_throw_error("/") == ""
assert normalise_url_path_or_throw_error("") == ""

assert (
normalise_url_path_or_throw_error("/.netlify/functions/api")
== "/.netlify/functions/api"
)
assert (
normalise_url_path_or_throw_error("/netlify/.functions/api")
== "/netlify/.functions/api"
)
assert (
normalise_url_path_or_throw_error("app.example.com/.netlify/functions/api")
== "/.netlify/functions/api"
)
assert (
normalise_url_path_or_throw_error("app.example.com/netlify/.functions/api")
== "/netlify/.functions/api"
)
assert normalise_url_path_or_throw_error("/app.example.com") == "/app.example.com"
with expectation as output:
assert normalise_url_path_or_throw_error(input) == output


def testing_URL_domain_normalisation():
Expand Down
27 changes: 19 additions & 8 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,14 +491,25 @@ async def create_users(


@contextmanager
def outputs(val: Any):
def outputs(value: Any):
"""
Outputs a value to assert.

Usage:
@mark.parametrize(["input", "expectation"], [(1, outputs(1)), (0, raises(Exception))])
def test_fn(input, expectation):
with expectation as expected_output:
assert 1 / input == expected_output
Can be used for a common interface in test parameters.

Example:
```python
@mark.parametrize(
("input", "expectation"),
[
(1, outputs(1)),
(0, raises(Exception)),
]
)
def test(input, expectation):
# In case of exceptions, the `raises` will catch it
# In normal execution, the `expected_output` contains the assertion value
with expectation as expected_output:
assert 1 / input == expected_output
```
"""
yield val
yield value