Skip to content

Commit 13b0425

Browse files
Fix OAuth1 double-encoding of RFC 3986 special characters in URL paths (#2341)
* Initial plan * Fix OAuth1 double-encoding issue for special characters in path segments Co-authored-by: alexeyzimarev <2821205+alexeyzimarev@users.noreply.github.com> * Add detailed security comment for Uri.UnescapeDataString usage Co-authored-by: alexeyzimarev <2821205+alexeyzimarev@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexeyzimarev <2821205+alexeyzimarev@users.noreply.github.com>
1 parent 92adf4c commit 13b0425

File tree

2 files changed

+84
-1
lines changed

2 files changed

+84
-1
lines changed

src/RestSharp/Authenticators/OAuth/OAuthTools.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,19 @@ static string ConstructRequestUrl(Uri url) {
154154
var secure = url is { Scheme: "https", Port: 443 };
155155
var port = basic || secure ? "" : $":{url.Port}";
156156

157-
return $"{url.Scheme}://{url.Host}{port}{url.AbsolutePath}";
157+
// Decode the path to avoid double-encoding when the path contains already-encoded characters.
158+
// For example, if a URL segment was added with AddUrlSegment("id", "value!"), it gets encoded
159+
// to "value%21" in the URL. When we extract url.AbsolutePath, it contains "%21" (encoded).
160+
// If we then call UrlEncodeRelaxed on it, Uri.EscapeDataString would encode the "%" to "%25",
161+
// resulting in "%2521" (double-encoded). By decoding first, we ensure proper single encoding.
162+
//
163+
// Security note: This is safe because:
164+
// - The url parameter is a validated Uri object constructed by RestSharp's BuildUri()
165+
// - The decoded path is immediately re-encoded by UrlEncodeRelaxed before use
166+
// - There is no direct user input involved in this internal OAuth signature calculation
167+
var decodedPath = Uri.UnescapeDataString(url.AbsolutePath);
168+
169+
return $"{url.Scheme}://{url.Host}{port}{decodedPath}";
158170
}
159171

160172
/// <summary>

test/RestSharp.Tests/Auth/OAuth1SignatureTests.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,75 @@ public void Generates_correct_signature_base() {
4949
"POST&https%3A%2F%2Fapi.twitter.com%2F1.1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521"
5050
);
5151
}
52+
53+
[Fact]
54+
public void Handles_path_with_exclamation_mark() {
55+
// Test that a path segment with ! is encoded correctly in the signature base
56+
var client = new RestClient("https://api.example.com");
57+
var request = new RestRequest("path/with!exclamation/resource", Method.Get);
58+
59+
const string method = "GET";
60+
var url = client.BuildUri(request).ToString();
61+
var parameters = new WebPairCollection();
62+
63+
_workflow.RequestUrl = url;
64+
var oauthParameters = _workflow.BuildProtectedResourceSignature(method, parameters);
65+
66+
var signatureBase = OAuthTools.ConcatenateRequestElements(method, url, oauthParameters.Parameters);
67+
68+
// The URL should be encoded with ! as %21 in the signature base
69+
signatureBase.Should().Contain("path%2Fwith%21exclamation%2Fresource");
70+
}
71+
72+
[Theory]
73+
[InlineData("path/with!exclamation", "%21")]
74+
[InlineData("path/with*asterisk", "%2A")]
75+
[InlineData("path/with'apostrophe", "%27")]
76+
[InlineData("path/with(paren", "%28")]
77+
[InlineData("path/with)paren", "%29")]
78+
public void Encodes_RFC3986_special_chars_in_path(string path, string encodedChar) {
79+
// Test that RFC 3986 special characters are properly encoded in path segments
80+
var client = new RestClient("https://api.example.com");
81+
var request = new RestRequest(path, Method.Get);
82+
83+
const string method = "GET";
84+
var url = client.BuildUri(request).ToString();
85+
var parameters = new WebPairCollection();
86+
87+
_workflow.RequestUrl = url;
88+
var oauthParameters = _workflow.BuildProtectedResourceSignature(method, parameters);
89+
90+
var signatureBase = OAuthTools.ConcatenateRequestElements(method, url, oauthParameters.Parameters);
91+
92+
// The URL should contain the encoded character in the signature base
93+
signatureBase.Should().Contain(encodedChar);
94+
}
95+
96+
[Theory]
97+
[InlineData("with!exclamation")]
98+
[InlineData("with*asterisk")]
99+
[InlineData("with'apostrophe")]
100+
[InlineData("with(paren")]
101+
[InlineData("with)paren")]
102+
public void Handles_url_segment_with_RFC3986_special_chars(string segmentValue) {
103+
// Test that URL segment parameters with RFC 3986 special characters don't get double-encoded
104+
var client = new RestClient("https://api.example.com");
105+
var request = new RestRequest("path/{segment}/resource", Method.Get);
106+
request.AddUrlSegment("segment", segmentValue);
107+
108+
const string method = "GET";
109+
var url = client.BuildUri(request).ToString();
110+
var parameters = new WebPairCollection();
111+
112+
_workflow.RequestUrl = url;
113+
var oauthParameters = _workflow.BuildProtectedResourceSignature(method, parameters);
114+
115+
var signatureBase = OAuthTools.ConcatenateRequestElements(method, url, oauthParameters.Parameters);
116+
117+
// The signature base should NOT contain double-encoded characters like %2521 (which is %25 + 21)
118+
signatureBase.Should().NotContain("%25");
119+
120+
// But it should contain properly encoded special chars
121+
signatureBase.Should().MatchRegex("%2[0-9A-F]");
122+
}
52123
}

0 commit comments

Comments
 (0)