Skip to content

Commit caba34c

Browse files
fix(security): apply fixes for URL injection and redirect URI validation (#49)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: deviantintegral <255023+deviantintegral@users.noreply.github.com>
1 parent 7f10eaf commit caba34c

File tree

4 files changed

+205
-3
lines changed

4 files changed

+205
-3
lines changed

src/flameconnect/b2c_login.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@
1313
import aiohttp
1414
import yarl
1515

16+
from flameconnect.const import CLIENT_ID
1617
from flameconnect.exceptions import AuthenticationError
1718

1819
_LOGGER = logging.getLogger(__name__)
1920

21+
_REDIRECT_URI_PREFIX = f"msal{CLIENT_ID}://auth"
22+
2023

2124
_B2C_POLICY = "B2C_1A_FirePhoneSignUpOrSignInWithPhoneOrEmail"
2225

@@ -287,8 +290,11 @@ async def b2c_login_with_credentials(auth_uri: str, email: str, password: str) -
287290
raise AuthenticationError(
288291
"Redirect without Location header"
289292
)
290-
# Custom-scheme redirect (msal{id}://auth)
291-
if location.startswith("msal") and "://auth" in location:
293+
# Custom-scheme redirect (msal{CLIENT_ID}://auth)
294+
if location.startswith(_REDIRECT_URI_PREFIX) and (
295+
len(location) == len(_REDIRECT_URI_PREFIX)
296+
or location[len(_REDIRECT_URI_PREFIX)] in ("?", "/")
297+
):
292298
_LOGGER.debug(
293299
"Captured custom-scheme redirect: %s",
294300
location[:120] + "...",

src/flameconnect/client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import logging
77
from dataclasses import replace
88
from typing import TYPE_CHECKING, Any
9+
from urllib.parse import quote
910

1011
import aiohttp
1112

@@ -210,7 +211,7 @@ async def get_fire_overview(self, fire_id: str) -> FireOverview:
210211
Returns:
211212
A FireOverview containing the fire identity and decoded parameters.
212213
"""
213-
url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}"
214+
url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={quote(fire_id, safe='')}"
214215
data: dict[str, Any] = await self._request("GET", url)
215216

216217
wifi: dict[str, Any] = data["WifiFireOverview"]

tests/test_b2c_login.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2341,3 +2341,145 @@ async def test_no_none_in_log_method_or_url(self, caplog):
23412341
assert " None" not in line.split(">>>")[1], (
23422342
f"Found 'None' in log line: {line}"
23432343
)
2344+
2345+
2346+
# -------------------------------------------------------------------
2347+
# Security regression tests
2348+
# -------------------------------------------------------------------
2349+
2350+
2351+
class TestRedirectUriValidation:
2352+
"""Security tests: redirect URI must match the exact expected prefix.
2353+
2354+
Before the fix, the check was:
2355+
location.startswith("msal") and "://auth" in location
2356+
2357+
This was too broad. A compromised server could send:
2358+
- msal{CLIENT_ID}://auth.evil.com?code=... (domain mismatch)
2359+
- msalEVIL://auth?code=... (wrong client ID)
2360+
2361+
Both would match the old check but must NOT match the new one,
2362+
which requires startswith(f"msal{CLIENT_ID}://auth") followed by
2363+
'?' or '/'.
2364+
"""
2365+
2366+
async def test_correct_redirect_uri_accepted(self):
2367+
"""The legitimate redirect URI is still accepted."""
2368+
login_resp = _make_mock_response(
2369+
status=200,
2370+
text=SAMPLE_B2C_HTML,
2371+
url=SAMPLE_PAGE_URL,
2372+
)
2373+
post_resp = _make_mock_response(status=200, text='{"status":"200"}')
2374+
confirmed_resp = _make_mock_response(
2375+
status=302,
2376+
headers={"Location": REDIRECT_URL},
2377+
)
2378+
2379+
session = _make_mock_session(
2380+
get=MagicMock(side_effect=[login_resp, confirmed_resp]),
2381+
post=MagicMock(return_value=post_resp),
2382+
)
2383+
2384+
with _patch_session(session):
2385+
result = await b2c_login_with_credentials(AUTH_URI, "user@test.com", "pass")
2386+
2387+
assert result == REDIRECT_URL
2388+
2389+
async def test_correct_redirect_uri_with_trailing_slash_accepted(self):
2390+
"""msal{CLIENT_ID}://auth/?... (with trailing slash) is also valid."""
2391+
redirect_with_slash = (
2392+
f"msal{_CLIENT_ID}://auth/?code=test-auth-code-123&state=test-state"
2393+
)
2394+
login_resp = _make_mock_response(
2395+
status=200,
2396+
text=SAMPLE_B2C_HTML,
2397+
url=SAMPLE_PAGE_URL,
2398+
)
2399+
post_resp = _make_mock_response(status=200, text='{"status":"200"}')
2400+
confirmed_resp = _make_mock_response(
2401+
status=302,
2402+
headers={"Location": redirect_with_slash},
2403+
)
2404+
2405+
session = _make_mock_session(
2406+
get=MagicMock(side_effect=[login_resp, confirmed_resp]),
2407+
post=MagicMock(return_value=post_resp),
2408+
)
2409+
2410+
with _patch_session(session):
2411+
result = await b2c_login_with_credentials(AUTH_URI, "user@test.com", "pass")
2412+
2413+
assert result == redirect_with_slash
2414+
2415+
async def test_attacker_subdomain_redirect_not_accepted(self):
2416+
"""msal{CLIENT_ID}://auth.evil.com?... must NOT be accepted.
2417+
2418+
The old check '://auth' in location would accept this because
2419+
the substring '://auth' appears in '://auth.evil.com'.
2420+
The new check requires startswith(f'msal{CLIENT_ID}://auth')
2421+
followed by '?' or '/', which the malicious URL fails because
2422+
'.' is neither.
2423+
"""
2424+
malicious_redirect = f"msal{_CLIENT_ID}://auth.evil.com?code=stolen&state=xyz"
2425+
login_resp = _make_mock_response(
2426+
status=200,
2427+
text=SAMPLE_B2C_HTML,
2428+
url=SAMPLE_PAGE_URL,
2429+
)
2430+
post_resp = _make_mock_response(status=200, text='{"status":"200"}')
2431+
# The malicious redirect followed by a 200 with no real redirect
2432+
malicious_resp = _make_mock_response(
2433+
status=302,
2434+
headers={"Location": malicious_redirect},
2435+
)
2436+
final_200 = _make_mock_response(
2437+
status=200,
2438+
text="<html>no redirect here</html>",
2439+
)
2440+
2441+
session = _make_mock_session(
2442+
get=MagicMock(side_effect=[login_resp, malicious_resp, final_200]),
2443+
post=MagicMock(return_value=post_resp),
2444+
)
2445+
2446+
with (
2447+
_patch_session(session),
2448+
pytest.raises(
2449+
AuthenticationError,
2450+
match="Reached 200 response without finding redirect URL",
2451+
),
2452+
):
2453+
await b2c_login_with_credentials(AUTH_URI, "user@test.com", "pass")
2454+
2455+
async def test_wrong_client_id_redirect_not_accepted(self):
2456+
"""msalWRONG_CLIENT_ID://auth?... must NOT be accepted."""
2457+
malicious_redirect = "msalDEADBEEF-0000-0000-0000-000000000000://auth?code=x"
2458+
login_resp = _make_mock_response(
2459+
status=200,
2460+
text=SAMPLE_B2C_HTML,
2461+
url=SAMPLE_PAGE_URL,
2462+
)
2463+
post_resp = _make_mock_response(status=200, text='{"status":"200"}')
2464+
malicious_resp = _make_mock_response(
2465+
status=302,
2466+
headers={"Location": malicious_redirect},
2467+
)
2468+
final_200 = _make_mock_response(
2469+
status=200,
2470+
text="<html>no redirect here</html>",
2471+
)
2472+
2473+
session = _make_mock_session(
2474+
get=MagicMock(side_effect=[login_resp, malicious_resp, final_200]),
2475+
post=MagicMock(return_value=post_resp),
2476+
)
2477+
2478+
with (
2479+
_patch_session(session),
2480+
pytest.raises(
2481+
AuthenticationError,
2482+
match="Reached 200 response without finding redirect URL",
2483+
),
2484+
):
2485+
await b2c_login_with_credentials(AUTH_URI, "user@test.com", "pass")

tests/test_client.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,3 +1419,56 @@ async def test_turn_off_no_mode_uses_default_temp(self, mock_api, token_auth):
14191419
assert raw[3] == FireMode.STANDBY
14201420
temp = float(raw[4]) + float(raw[5]) / 10.0
14211421
assert temp == pytest.approx(22.0)
1422+
1423+
1424+
# -------------------------------------------------------------------
1425+
# Security regression tests
1426+
# -------------------------------------------------------------------
1427+
1428+
1429+
class TestGetFireOverviewUrlEncoding:
1430+
"""Security tests: fire_id must be URL-encoded in get_fire_overview.
1431+
1432+
A compromised API server could return a FireId containing URL
1433+
special characters (e.g. '&evil=true') to inject extra query
1434+
parameters into subsequent requests. The fix URL-encodes fire_id
1435+
via urllib.parse.quote() so injected characters are escaped.
1436+
"""
1437+
1438+
async def test_ampersand_in_fire_id_is_encoded(self, mock_api, token_auth):
1439+
"""'&' in fire_id must be percent-encoded, not passed raw."""
1440+
fire_id = "abc&evil=true"
1441+
# The URL must encode '&' as '%26' and '=' as '%3D'
1442+
encoded_id = "abc%26evil%3Dtrue"
1443+
url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={encoded_id}"
1444+
mock_api.get(url, payload={"WifiFireOverview": {"FireId": fire_id}})
1445+
1446+
async with FlameConnectClient(token_auth) as client:
1447+
overview = await client.get_fire_overview(fire_id)
1448+
1449+
assert overview.fire.fire_id == fire_id
1450+
1451+
async def test_hash_in_fire_id_is_encoded(self, mock_api, token_auth):
1452+
"""'#' in fire_id must be percent-encoded."""
1453+
fire_id = "abc#fragment"
1454+
encoded_id = "abc%23fragment"
1455+
url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={encoded_id}"
1456+
mock_api.get(url, payload={"WifiFireOverview": {"FireId": fire_id}})
1457+
1458+
async with FlameConnectClient(token_auth) as client:
1459+
overview = await client.get_fire_overview(fire_id)
1460+
1461+
assert overview.fire.fire_id == fire_id
1462+
1463+
async def test_normal_fire_id_unchanged(
1464+
self, mock_api, token_auth, get_fire_overview_payload
1465+
):
1466+
"""A plain alphanumeric fire_id is unaffected by encoding."""
1467+
fire_id = "test-fire-001"
1468+
url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={fire_id}"
1469+
mock_api.get(url, payload=get_fire_overview_payload)
1470+
1471+
async with FlameConnectClient(token_auth) as client:
1472+
overview = await client.get_fire_overview(fire_id)
1473+
1474+
assert overview.fire.fire_id == fire_id

0 commit comments

Comments
 (0)