diff options
| -rw-r--r-- | Jellyfin.Api/Controllers/TestsController.cs | 35 | ||||
| -rw-r--r-- | Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs | 10 | ||||
| -rw-r--r-- | Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs | 37 | ||||
| -rw-r--r-- | Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs | 83 | ||||
| -rw-r--r-- | Jellyfin.Server/Startup.cs | 1 | ||||
| -rw-r--r-- | tests/Jellyfin.Api.Tests/Controllers/EncodedQueryStringTest.cs | 39 |
6 files changed, 205 insertions, 0 deletions
diff --git a/Jellyfin.Api/Controllers/TestsController.cs b/Jellyfin.Api/Controllers/TestsController.cs new file mode 100644 index 000000000..1d1e1899f --- /dev/null +++ b/Jellyfin.Api/Controllers/TestsController.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Api.Constants; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Controller for testing. + /// </summary> + public class TestsController : BaseJellyfinApiController + { + /// <summary> + /// Tests the url decoding. + /// </summary> + /// <param name="params">Parameters to echo back in the response.</param> + /// <returns>An <see cref="OkResult"/>.</returns> + /// <response code="200">Information retrieved.</response> + [HttpGet("UrlDecode")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ContentResult TestUrlDecoding([FromQuery]Dictionary<string, string>? @params = null) + { + return new ContentResult() + { + Content = (@params != null && @params.Count > 0) + ? string.Join("&", @params.Select(x => x.Key + "=" + x.Value)) + : string.Empty, + ContentType = "text/plain; charset=utf-8", + StatusCode = 200 + }; + } + } +} diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index 88e2b4152..e29167747 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -79,6 +79,16 @@ namespace Jellyfin.Server.Extensions } /// <summary> + /// Enables url decoding before binding to the application pipeline. + /// </summary> + /// <param name="appBuilder">The <see cref="IApplicationBuilder"/>.</param> + /// <returns>The updated application builder.</returns> + public static IApplicationBuilder UseQueryStringDecoding(this IApplicationBuilder appBuilder) + { + return appBuilder.UseMiddleware<QueryStringDecodingMiddleware>(); + } + + /// <summary> /// Adds base url redirection to the application pipeline. /// </summary> /// <param name="appBuilder">The application builder.</param> diff --git a/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs b/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs new file mode 100644 index 000000000..08fbbce0b --- /dev/null +++ b/Jellyfin.Server/Middleware/QueryStringDecodingMiddleware.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Middleware +{ + /// <summary> + /// URL decodes the querystring before binding. + /// </summary> + public class QueryStringDecodingMiddleware + { + private readonly RequestDelegate _next; + + /// <summary> + /// Initializes a new instance of the <see cref="QueryStringDecodingMiddleware"/> class. + /// </summary> + /// <param name="next">The next delegate in the pipeline.</param> + public QueryStringDecodingMiddleware(RequestDelegate next) + { + _next = next; + } + + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext) + { + httpContext.Features.Set<IQueryFeature>(new UrlDecodeQueryFeature(httpContext.Features.Get<IQueryFeature>())); + + await _next(httpContext).ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs b/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs new file mode 100644 index 000000000..44b30baac --- /dev/null +++ b/Jellyfin.Server/Middleware/UrlDecodeQueryFeature.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Linq; +using System.Web; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; + +namespace Jellyfin.Server.Middleware +{ + /// <summary> + /// Defines the <see cref="UrlDecodeQueryFeature"/>. + /// </summary> + public class UrlDecodeQueryFeature : IQueryFeature + { + private IQueryCollection? _store; + + /// <summary> + /// Initializes a new instance of the <see cref="UrlDecodeQueryFeature"/> class. + /// </summary> + /// <param name="feature">The <see cref="IQueryFeature"/> instance.</param> + public UrlDecodeQueryFeature(IQueryFeature feature) + { + Query = feature.Query; + } + + /// <summary> + /// Gets or sets a value indicating the url decoded <see cref="IQueryCollection"/>. + /// </summary> + public IQueryCollection Query + { + get + { + return _store ?? QueryCollection.Empty; + } + + set + { + // Only interested in where the querystring is encoded which shows up as one key with nothing in the value. + if (value.Count != 1) + { + _store = value; + return; + } + + // Encoded querystrings have no value, so don't process anything if a value is present. + var kvp = value.First(); + if (!string.IsNullOrEmpty(kvp.Value)) + { + _store = value; + return; + } + + // Unencode and re-parse querystring. + var unencodedKey = HttpUtility.UrlDecode(kvp.Key); + + if (string.Equals(unencodedKey, kvp.Key, System.StringComparison.Ordinal)) + { + // Don't do anything if it's not encoded. + _store = value; + return; + } + + var pairs = new Dictionary<string, StringValues>(); + var queryString = unencodedKey.Split('&', System.StringSplitOptions.RemoveEmptyEntries); + + foreach (var pair in queryString) + { + var item = pair.Split('=', System.StringSplitOptions.RemoveEmptyEntries); + if (item.Length > 0) + { + pairs.Add(item[0], new StringValues(item.Length == 2 ? item[1] : string.Empty)); + } + else + { + pairs.Add(pair, string.Empty); + } + } + + _store = new QueryCollection(pairs); + } + } + } +} diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index f75139884..60cdc2f6f 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -160,6 +160,7 @@ namespace Jellyfin.Server mainApp.UseAuthentication(); mainApp.UseJellyfinApiSwagger(_serverConfigurationManager); + mainApp.UseQueryStringDecoding(); mainApp.UseRouting(); mainApp.UseAuthorization(); diff --git a/tests/Jellyfin.Api.Tests/Controllers/EncodedQueryStringTest.cs b/tests/Jellyfin.Api.Tests/Controllers/EncodedQueryStringTest.cs new file mode 100644 index 000000000..d6a423dcd --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/EncodedQueryStringTest.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers +{ + /// <summary> + /// Defines the test for encoded querystrings in the url. + /// </summary> + public class EncodedQueryStringTest : IClassFixture<JellyfinApplicationFactory> + { + private readonly JellyfinApplicationFactory _factory; + + public EncodedQueryStringTest(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Theory] + [InlineData("a=1&b=2&c=3", "a=1&b=2&c=3")] // won't be processed as there is more than 1. + [InlineData("a=1", "a=1")] // won't be processed as it has a value + [InlineData("%3D", "==")] // will decode with an empty string value '=' = ''. + [InlineData("a%3D1%26b%3D2%26c%3D3", "a=1&b=2&c=3")] // will be processed. + public async Task Ensure_Decoding_Of_Urls_Is_Working(string sourceUrl, string unencodedUrl) + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("Tests/UrlDecode?" + sourceUrl).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + string reply = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Equal(unencodedUrl, reply); + } + } +} |
