Skip to content

Commit 23c0a3f

Browse files
committed
Fix parsing of URLs with query or fragment delimiters but no path component
1 parent 0ced6b9 commit 23c0a3f

File tree

3 files changed

+113
-21
lines changed

3 files changed

+113
-21
lines changed

ChangeLog

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
2025-10-22 Frederik Seiffert <frederik@algoriddim.com>
2+
3+
* Source/NSURL.m: Fix parsing of URLs with query or fragment delimiters
4+
but no path component.
5+
16
2025-10-01 Richard Frith-Macdonald <rfm@gnu.org>
27

38
* Source/Additions/Unicode.m:

Source/NSURL.m

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -515,18 +515,18 @@ static id clientForHandle(void *data, NSURLHandle *hdl)
515515
}
516516

517517
/*
518-
* Check a string to see if it contains only legal data characters
519-
* or percent escape sequences.
518+
* Check a bounded string (up to end pointer) to see if it contains only
519+
* legal data characters or percent escape sequences.
520520
*/
521-
static BOOL legal(const char *str, const char *extras)
521+
static BOOL legal_bounded(const char *str, const char *end, const char *extras)
522522
{
523523
const char *mark = "-_.!~*'()";
524524

525525
if (str != 0)
526526
{
527-
while (*str != 0)
527+
while (str < end)
528528
{
529-
if (*str == '%' && isxdigit(str[1]) && isxdigit(str[2]))
529+
if (*str == '%' && str + 2 < end && isxdigit(str[1]) && isxdigit(str[2]))
530530
{
531531
str += 3;
532532
}
@@ -551,6 +551,17 @@ static BOOL legal(const char *str, const char *extras)
551551
return YES;
552552
}
553553

554+
/*
555+
* Check a string to see if it contains only legal data characters
556+
* or percent escape sequences. Wrapper around legal_bounded.
557+
*/
558+
static BOOL legal(const char *str, const char *extras)
559+
{
560+
if (str == 0)
561+
return YES;
562+
return legal_bounded(str, str + strlen(str), extras);
563+
}
564+
554565
/*
555566
* Convert percent escape sequences to individual characters.
556567
*/
@@ -995,41 +1006,78 @@ - (id) initWithString: (NSString*)aUrlString
9951006
/*
9961007
* Set 'end' to point to the start of the path, or just past
9971008
* the 'authority' if there is no path.
1009+
* Check for delimiters in order: '/' (path), '?' (query), '#' (fragment).
1010+
* We find the authority end without modifying the string, using legal_bounded()
1011+
* for validation (RFC 3986).
9981012
*/
1013+
char *authEnd; // End of authority section
1014+
char *pathStart = NULL; // Where path/query/fragment starts
1015+
9991016
end = strchr(start, '/');
10001017
if (end == 0)
10011018
{
10021019
buf->hasNoPath = YES;
1003-
end = &start[strlen(start)];
1020+
char *alt = strchr(start, '?');
1021+
if (alt == 0)
1022+
{
1023+
alt = strchr(start, '#');
1024+
}
1025+
if (alt == 0)
1026+
{
1027+
authEnd = &start[strlen(start)];
1028+
pathStart = authEnd;
1029+
}
1030+
else
1031+
{
1032+
authEnd = alt;
1033+
pathStart = alt;
1034+
}
10041035
}
10051036
else
10061037
{
1038+
authEnd = end;
1039+
pathStart = end + 1; // Skip the '/' we found
10071040
*end++ = '\0';
10081041
}
10091042

10101043
/*
1011-
* Parser username:password part
1044+
* Parser username:password part within authority bounds
10121045
*/
10131046
ptr = strchr(start, '@');
1014-
if (ptr != 0)
1047+
if (ptr != 0 && ptr < authEnd)
10151048
{
10161049
buf->user = start;
1050+
char *userEnd = ptr;
10171051
*ptr++ = '\0';
10181052
start = ptr;
1019-
if (legal(buf->user, ";:&=+$,") == NO)
1053+
/* Validate user[:password] without the null terminator at ':' */
1054+
char *colonPos = strchr(buf->user, ':');
1055+
if (colonPos != 0 && colonPos < userEnd)
10201056
{
1021-
[NSException raise: NSInvalidArgumentException
1022-
format: @"[%@ %@](%@, %@) "
1023-
@"illegal character in user/password part",
1024-
NSStringFromClass([self class]),
1025-
NSStringFromSelector(_cmd),
1026-
aUrlString, aBaseUrl];
1057+
if (legal_bounded(buf->user, colonPos, ";:&=+$,") == NO
1058+
|| legal_bounded(colonPos + 1, userEnd, ";:&=+$,") == NO)
1059+
{
1060+
[NSException raise: NSInvalidArgumentException
1061+
format: @"[%@ %@](%@, %@) "
1062+
@"illegal character in user/password part",
1063+
NSStringFromClass([self class]),
1064+
NSStringFromSelector(_cmd),
1065+
aUrlString, aBaseUrl];
1066+
}
1067+
*colonPos = '\0';
1068+
buf->password = colonPos + 1;
10271069
}
1028-
ptr = strchr(buf->user, ':');
1029-
if (ptr != 0)
1070+
else
10301071
{
1031-
*ptr++ = '\0';
1032-
buf->password = ptr;
1072+
if (legal_bounded(buf->user, userEnd, ";:&=+$,") == NO)
1073+
{
1074+
[NSException raise: NSInvalidArgumentException
1075+
format: @"[%@ %@](%@, %@) "
1076+
@"illegal character in user/password part",
1077+
NSStringFromClass([self class]),
1078+
NSStringFromSelector(_cmd),
1079+
aUrlString, aBaseUrl];
1080+
}
10331081
}
10341082
}
10351083

@@ -1142,11 +1190,22 @@ - (id) initWithString: (NSString*)aUrlString
11421190
}
11431191
}
11441192
}
1145-
start = end;
1193+
start = pathStart;
11461194
/* Check for a legal host, unless it's an ipv6 address
11471195
* (which would have been checked earlier).
1196+
* Use legal_bounded to validate only the host portion without modifying the string.
11481197
*/
1149-
if (*buf->host != '[' && legal(buf->host, "-") == NO)
1198+
char *hostEnd = authEnd;
1199+
/* Account for port if present */
1200+
if (*buf->host != '[')
1201+
{
1202+
char *colon = strchr(buf->host, ':');
1203+
if (colon != 0 && colon < authEnd)
1204+
{
1205+
hostEnd = colon;
1206+
}
1207+
}
1208+
if (*buf->host != '[' && legal_bounded(buf->host, hostEnd, "-") == NO)
11501209
{
11511210
[NSException raise: NSInvalidArgumentException
11521211
format: @"[%@ %@](%@, %@) "

Tests/base/NSURL/basic.m

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,34 @@ int main()
392392
PASS_EQUAL([rel absoluteString], @"data:,$2A", "relative data URL works");
393393
PASS_EQUAL([rel baseURL], nil, "Base URL of relative data URL is nil");
394394

395+
/* Test URLs with query but no path */
396+
url = [NSURL URLWithString: @"https://example.com?key=value"];
397+
PASS(url != nil, "URL with query but no path should be valid");
398+
PASS_EQUAL([url scheme], @"https", "scheme of https://example.com?key=value is https");
399+
PASS_EQUAL([url host], @"example.com", "host of https://example.com?key=value is example.com");
400+
PASS_EQUAL([url path], @"", "path of https://example.com?key=value is empty");
401+
PASS_EQUAL([url query], @"key=value", "query of https://example.com?key=value is key=value");
402+
PASS_EQUAL([url absoluteString], @"https://example.com?key=value", "absoluteString works for URL with query but no path");
403+
404+
/* Test URLs with fragment but no path */
405+
url = [NSURL URLWithString: @"https://example.com#section"];
406+
PASS(url != nil, "URL with fragment but no path should be valid");
407+
PASS_EQUAL([url scheme], @"https", "scheme of https://example.com#section is https");
408+
PASS_EQUAL([url host], @"example.com", "host of https://example.com#section is example.com");
409+
PASS_EQUAL([url path], @"", "path of https://example.com#section is empty");
410+
PASS_EQUAL([url fragment], @"section", "fragment of https://example.com#section is section");
411+
PASS_EQUAL([url absoluteString], @"https://example.com#section", "absoluteString works for URL with fragment but no path");
412+
413+
/* Test URLs with query and fragment but no path */
414+
url = [NSURL URLWithString: @"https://example.com?key=value#section"];
415+
PASS(url != nil, "URL with query and fragment but no path should be valid");
416+
PASS_EQUAL([url scheme], @"https", "scheme of https://example.com?key=value#section is https");
417+
PASS_EQUAL([url host], @"example.com", "host of https://example.com?key=value#section is example.com");
418+
PASS_EQUAL([url path], @"", "path of https://example.com?key=value#section is empty");
419+
PASS_EQUAL([url query], @"key=value", "query of https://example.com?key=value#section is key=value");
420+
PASS_EQUAL([url fragment], @"section", "fragment of https://example.com?key=value#section is section");
421+
PASS_EQUAL([url absoluteString], @"https://example.com?key=value#section", "absoluteString works for URL with query and fragment but no path");
422+
395423
///NSURLQueryItem
396424

397425
//OSX behavior is to return query item with an empty string name

0 commit comments

Comments
 (0)