Browse Source

Add option to disable adding trailing slash #2449 (#12669)

Middlewares affected:
- DefaultFilesMiddleware
- DirectoryBrowserMiddleware
Mateusz Wójcik 6 năm trước cách đây
mục cha
commit
c703093346

+ 2 - 0
src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs

@@ -120,12 +120,14 @@ namespace Microsoft.AspNetCore.StaticFiles.Infrastructure
     {
         public SharedOptions() { }
         public Microsoft.Extensions.FileProviders.IFileProvider FileProvider { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
+        public bool RedirectToAppendTrailingSlash { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
         public Microsoft.AspNetCore.Http.PathString RequestPath { get { throw null; } set { } }
     }
     public abstract partial class SharedOptionsBase
     {
         protected SharedOptionsBase(Microsoft.AspNetCore.StaticFiles.Infrastructure.SharedOptions sharedOptions) { }
         public Microsoft.Extensions.FileProviders.IFileProvider FileProvider { get { throw null; } set { } }
+        public bool RedirectToAppendTrailingSlash { get { throw null; } set { } }
         public Microsoft.AspNetCore.Http.PathString RequestPath { get { throw null; } set { } }
         protected Microsoft.AspNetCore.StaticFiles.Infrastructure.SharedOptions SharedOptions { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
     }

+ 3 - 7
src/Middleware/StaticFiles/src/DefaultFilesMiddleware.cs

@@ -80,17 +80,13 @@ namespace Microsoft.AspNetCore.StaticFiles
                         {
                             // If the path matches a directory but does not end in a slash, redirect to add the slash.
                             // This prevents relative links from breaking.
-                            if (!Helpers.PathEndsInSlash(context.Request.Path))
+                            if (!Helpers.PathEndsInSlash(context.Request.Path) && _options.RedirectToAppendTrailingSlash)
                             {
-                                context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
-                                var request = context.Request;
-                                var redirect = UriHelper.BuildAbsolute(request.Scheme, request.Host, request.PathBase, request.Path + "/", request.QueryString);
-                                context.Response.Headers[HeaderNames.Location] = redirect;
+                                Helpers.RedirectToPathWithSlash(context);
                                 return Task.CompletedTask;
                             }
-
                             // Match found, re-write the url. A later middleware will actually serve the file.
-                            context.Request.Path = new PathString(context.Request.Path.Value + defaultFile);
+                            context.Request.Path = new PathString(Helpers.GetPathValueWithSlash(context.Request.Path) + defaultFile);
                             break;
                         }
                     }

+ 2 - 5
src/Middleware/StaticFiles/src/DirectoryBrowserMiddleware.cs

@@ -87,12 +87,9 @@ namespace Microsoft.AspNetCore.StaticFiles
             {
                 // If the path matches a directory but does not end in a slash, redirect to add the slash.
                 // This prevents relative links from breaking.
-                if (!Helpers.PathEndsInSlash(context.Request.Path))
+                if (!Helpers.PathEndsInSlash(context.Request.Path) && _options.RedirectToAppendTrailingSlash)
                 {
-                    context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
-                    var request = context.Request;
-                    var redirect = UriHelper.BuildAbsolute(request.Scheme, request.Host, request.PathBase, request.Path + "/", request.QueryString);
-                    context.Response.Headers[HeaderNames.Location] = redirect;
+                    Helpers.RedirectToPathWithSlash(context);
                     return Task.CompletedTask;
                 }
 

+ 22 - 1
src/Middleware/StaticFiles/src/Helpers.cs

@@ -2,9 +2,12 @@
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;
+using System.Threading.Tasks;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Extensions;
 using Microsoft.Extensions.FileProviders;
+using Microsoft.Net.Http.Headers;
 
 namespace Microsoft.AspNetCore.StaticFiles
 {
@@ -12,7 +15,8 @@ namespace Microsoft.AspNetCore.StaticFiles
     {
         internal static IFileProvider ResolveFileProvider(IWebHostEnvironment hostingEnv)
         {
-            if (hostingEnv.WebRootFileProvider == null) {
+            if (hostingEnv.WebRootFileProvider == null)
+            {
                 throw new InvalidOperationException("Missing FileProvider.");
             }
             return hostingEnv.WebRootFileProvider;
@@ -28,6 +32,23 @@ namespace Microsoft.AspNetCore.StaticFiles
             return path.Value.EndsWith("/", StringComparison.Ordinal);
         }
 
+        internal static string GetPathValueWithSlash(PathString path)
+        {
+            if (!PathEndsInSlash(path))
+            {
+                return path.Value + "/";
+            }
+            return path.Value;
+        }
+
+        internal static void RedirectToPathWithSlash(HttpContext context)
+        {
+            context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
+            var request = context.Request;
+            var redirect = UriHelper.BuildAbsolute(request.Scheme, request.Host, request.PathBase, request.Path + "/", request.QueryString);
+            context.Response.Headers[HeaderNames.Location] = redirect;
+        }
+
         internal static bool TryMatchPath(HttpContext context, PathString matchUrl, bool forDirectory, out PathString subpath)
         {
             var path = context.Request.Path;

+ 5 - 0
src/Middleware/StaticFiles/src/Infrastructure/SharedOptions.cs

@@ -42,5 +42,10 @@ namespace Microsoft.AspNetCore.StaticFiles.Infrastructure
         /// The file system used to locate resources
         /// </summary>
         public IFileProvider FileProvider { get; set; }
+
+        /// <summary>
+        /// Indicates whether to redirect to add a trailing slash at the end of path. Relative resource links may require this.
+        /// </summary>
+        public bool RedirectToAppendTrailingSlash { get; set; } = true;
     }
 }

+ 9 - 0
src/Middleware/StaticFiles/src/Infrastructure/SharedOptionsBase.cs

@@ -48,5 +48,14 @@ namespace Microsoft.AspNetCore.StaticFiles.Infrastructure
             get { return SharedOptions.FileProvider; }
             set { SharedOptions.FileProvider = value; }
         }
+
+        /// <summary>
+        /// Indicates whether to redirect to add a trailing slash at the end of path. Relative resource links may require this.
+        /// </summary>
+        public bool RedirectToAppendTrailingSlash
+        {
+            get { return SharedOptions.RedirectToAppendTrailingSlash; }
+            set { SharedOptions.RedirectToAppendTrailingSlash = value; }
+        }
     }
 }

+ 70 - 20
src/Middleware/StaticFiles/test/UnitTests/DefaultFilesMiddlewareTests.cs

@@ -38,9 +38,14 @@ namespace Microsoft.AspNetCore.StaticFiles
         [InlineData("/subdir", @".", "/subdir/missing.dir")]
         [InlineData("/subdir", @".", "/subdir/missing.dir/")]
         [InlineData("", @"./", "/missing.dir")]
-        public async Task NoMatch_PassesThrough_All(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("", @".", "/missing.dir", false)]
+        [InlineData("", @".", "/missing.dir/", false)]
+        [InlineData("/subdir", @".", "/subdir/missing.dir", false)]
+        [InlineData("/subdir", @".", "/subdir/missing.dir/", false)]
+        [InlineData("", @"./", "/missing.dir", false)]
+        public async Task NoMatch_PassesThrough_All(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await NoMatch_PassesThrough(baseUrl, baseDir, requestUrl);
+            await NoMatch_PassesThrough(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
         [ConditionalTheory]
@@ -48,12 +53,14 @@ namespace Microsoft.AspNetCore.StaticFiles
         [OSSkipCondition(OperatingSystems.MacOSX)]
         [InlineData("", @".\", "/missing.dir")]
         [InlineData("", @".\", "/Missing.dir")]
-        public async Task NoMatch_PassesThrough_Windows(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("", @".\", "/missing.dir", false)]
+        [InlineData("", @".\", "/Missing.dir", false)]
+        public async Task NoMatch_PassesThrough_Windows(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await NoMatch_PassesThrough(baseUrl, baseDir, requestUrl);
+            await NoMatch_PassesThrough(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
-        private async Task NoMatch_PassesThrough(string baseUrl, string baseDir, string requestUrl)
+        private async Task NoMatch_PassesThrough(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
             using (var fileProvider = new PhysicalFileProvider(Path.Combine(AppContext.BaseDirectory, baseDir)))
             {
@@ -62,7 +69,8 @@ namespace Microsoft.AspNetCore.StaticFiles
                     app.UseDefaultFiles(new DefaultFilesOptions
                     {
                         RequestPath = new PathString(baseUrl),
-                        FileProvider = fileProvider
+                        FileProvider = fileProvider,
+                        RedirectToAppendTrailingSlash = appendTrailingSlash
                     });
                     app.Run(context => context.Response.WriteAsync(context.Request.Path.Value));
                 });
@@ -102,7 +110,7 @@ namespace Microsoft.AspNetCore.StaticFiles
                             FileProvider = fileProvider
                         });
 
-                        app.UseEndpoints(endpoints => {});
+                        app.UseEndpoints(endpoints => { });
                     },
                     services => { services.AddDirectoryBrowser(); services.AddRouting(); });
 
@@ -118,9 +126,19 @@ namespace Microsoft.AspNetCore.StaticFiles
         [InlineData("", @"./SubFolder", "/")]
         [InlineData("", @"./SubFolder", "/你好/")]
         [InlineData("", @"./SubFolder", "/你好/世界/")]
-        public async Task FoundDirectoryWithDefaultFile_PathModified_All(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("", @".", "/SubFolder/", false)]
+        [InlineData("", @"./", "/SubFolder/", false)]
+        [InlineData("", @"./SubFolder", "/", false)]
+        [InlineData("", @"./SubFolder", "/你好/", false)]
+        [InlineData("", @"./SubFolder", "/你好/世界/", false)]
+        [InlineData("", @".", "/SubFolder", false)]
+        [InlineData("", @"./", "/SubFolder", false)]
+        [InlineData("", @"./SubFolder", "", false)]
+        [InlineData("", @"./SubFolder", "/你好", false)]
+        [InlineData("", @"./SubFolder", "/你好/世界", false)]
+        public async Task FoundDirectoryWithDefaultFile_PathModified_All(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await FoundDirectoryWithDefaultFile_PathModified(baseUrl, baseDir, requestUrl);
+            await FoundDirectoryWithDefaultFile_PathModified(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
         [ConditionalTheory]
@@ -130,12 +148,20 @@ namespace Microsoft.AspNetCore.StaticFiles
         [InlineData("", @".\subFolder", "/")]
         [InlineData("", @".\SubFolder", "/你好/")]
         [InlineData("", @".\SubFolder", "/你好/世界/")]
-        public async Task FoundDirectoryWithDefaultFile_PathModified_Windows(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("", @".\", "/SubFolder/", false)]
+        [InlineData("", @".\subFolder", "/", false)]
+        [InlineData("", @".\SubFolder", "/你好/", false)]
+        [InlineData("", @".\SubFolder", "/你好/世界/", false)]
+        [InlineData("", @".\", "/SubFolder", false)]
+        [InlineData("", @".\subFolder", "", false)]
+        [InlineData("", @".\SubFolder", "/你好", false)]
+        [InlineData("", @".\SubFolder", "/你好/世界", false)]
+        public async Task FoundDirectoryWithDefaultFile_PathModified_Windows(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await FoundDirectoryWithDefaultFile_PathModified(baseUrl, baseDir, requestUrl);
+            await FoundDirectoryWithDefaultFile_PathModified(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
-        private async Task FoundDirectoryWithDefaultFile_PathModified(string baseUrl, string baseDir, string requestUrl)
+        private async Task FoundDirectoryWithDefaultFile_PathModified(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
             using (var fileProvider = new PhysicalFileProvider(Path.Combine(AppContext.BaseDirectory, baseDir)))
             {
@@ -144,14 +170,17 @@ namespace Microsoft.AspNetCore.StaticFiles
                     app.UseDefaultFiles(new DefaultFilesOptions
                     {
                         RequestPath = new PathString(baseUrl),
-                        FileProvider = fileProvider
+                        FileProvider = fileProvider,
+                        RedirectToAppendTrailingSlash = appendTrailingSlash
                     });
                     app.Run(context => context.Response.WriteAsync(context.Request.Path.Value));
                 });
 
                 var response = await server.CreateClient().GetAsync(requestUrl);
+
                 Assert.Equal(HttpStatusCode.OK, response.StatusCode);
-                Assert.Equal(requestUrl + "default.html", await response.Content.ReadAsStringAsync()); // Should be modified
+                var requestUrlWithSlash = requestUrl.EndsWith("/") ? requestUrl : requestUrl + "/";
+                Assert.Equal(requestUrlWithSlash + "default.html", await response.Content.ReadAsStringAsync()); // Should be modified and be valid path to file
             }
         }
 
@@ -202,9 +231,17 @@ namespace Microsoft.AspNetCore.StaticFiles
         [InlineData("/SubFolder", @".", "/somedir/")]
         [InlineData("", @"./SubFolder", "/")]
         [InlineData("", @"./SubFolder/", "/")]
-        public async Task PostDirectory_PassesThrough_All(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("/SubFolder", @"./", "/SubFolder/", false)]
+        [InlineData("/SubFolder", @".", "/somedir/", false)]
+        [InlineData("", @"./SubFolder", "/", false)]
+        [InlineData("", @"./SubFolder/", "/", false)]
+        [InlineData("/SubFolder", @"./", "/SubFolder", false)]
+        [InlineData("/SubFolder", @".", "/somedir", false)]
+        [InlineData("", @"./SubFolder", "", false)]
+        [InlineData("", @"./SubFolder/", "", false)]
+        public async Task PostDirectory_PassesThrough_All(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await PostDirectory_PassesThrough(baseUrl, baseDir, requestUrl);
+            await PostDirectory_PassesThrough(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
         [ConditionalTheory]
@@ -213,24 +250,37 @@ namespace Microsoft.AspNetCore.StaticFiles
         [InlineData("/SubFolder", @".\", "/SubFolder/")]
         [InlineData("", @".\SubFolder", "/")]
         [InlineData("", @".\SubFolder\", "/")]
-        public async Task PostDirectory_PassesThrough_Windows(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("/SubFolder", @".\", "/SubFolder/", false)]
+        [InlineData("", @".\SubFolder", "/", false)]
+        [InlineData("", @".\SubFolder\", "/", false)]
+        [InlineData("/SubFolder", @".\", "/SubFolder", false)]
+        [InlineData("", @".\SubFolder", "", false)]
+        [InlineData("", @".\SubFolder\", "", false)]
+        public async Task PostDirectory_PassesThrough_Windows(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await PostDirectory_PassesThrough(baseUrl, baseDir, requestUrl);
+            await PostDirectory_PassesThrough(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
-        private async Task PostDirectory_PassesThrough(string baseUrl, string baseDir, string requestUrl)
+        private async Task PostDirectory_PassesThrough(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
             using (var fileProvider = new PhysicalFileProvider(Path.Combine(AppContext.BaseDirectory, baseDir)))
             {
                 var server = StaticFilesTestServer.Create(app => app.UseDefaultFiles(new DefaultFilesOptions
                 {
                     RequestPath = new PathString(baseUrl),
-                    FileProvider = fileProvider
+                    FileProvider = fileProvider,
+                    RedirectToAppendTrailingSlash = appendTrailingSlash
                 }));
                 var response = await server.CreateRequest(requestUrl).GetAsync();
 
                 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); // Passed through
             }
         }
+
+        [Fact]
+        public void Options_AppendTrailingSlashByDefault()
+        {
+            Assert.True(new DefaultFilesOptions().RedirectToAppendTrailingSlash);
+        }
     }
 }

+ 76 - 25
src/Middleware/StaticFiles/test/UnitTests/DirectoryBrowserMiddlewareTests.cs

@@ -56,9 +56,14 @@ namespace Microsoft.AspNetCore.StaticFiles
         [InlineData("/subdir", @".", "/subdir/missing.dir")]
         [InlineData("/subdir", @".", "/subdir/missing.dir/")]
         [InlineData("", @"./", "/missing.dir")]
-        public async Task NoMatch_PassesThrough_All(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("", @".", "/missing.dir", false)]
+        [InlineData("", @".", "/missing.dir/", false)]
+        [InlineData("/subdir", @".", "/subdir/missing.dir", false)]
+        [InlineData("/subdir", @".", "/subdir/missing.dir/", false)]
+        [InlineData("", @"./", "/missing.dir", false)]
+        public async Task NoMatch_PassesThrough_All(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await NoMatch_PassesThrough(baseUrl, baseDir, requestUrl);
+            await NoMatch_PassesThrough(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
         [ConditionalTheory]
@@ -66,12 +71,14 @@ namespace Microsoft.AspNetCore.StaticFiles
         [OSSkipCondition(OperatingSystems.MacOSX)]
         [InlineData("", @".\", "/missing.dir")]
         [InlineData("", @".\", "/Missing.dir")]
-        public async Task NoMatch_PassesThrough_Windows(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("", @".\", "/missing.dir", false)]
+        [InlineData("", @".\", "/Missing.dir", false)]
+        public async Task NoMatch_PassesThrough_Windows(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await NoMatch_PassesThrough(baseUrl, baseDir, requestUrl);
+            await NoMatch_PassesThrough(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
-        private async Task NoMatch_PassesThrough(string baseUrl, string baseDir, string requestUrl)
+        private async Task NoMatch_PassesThrough(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
             using (var fileProvider = new PhysicalFileProvider(Path.Combine(AppContext.BaseDirectory, baseDir)))
             {
@@ -79,7 +86,8 @@ namespace Microsoft.AspNetCore.StaticFiles
                     app => app.UseDirectoryBrowser(new DirectoryBrowserOptions
                     {
                         RequestPath = new PathString(baseUrl),
-                        FileProvider = fileProvider
+                        FileProvider = fileProvider,
+                        RedirectToAppendTrailingSlash = appendTrailingSlash
                     }),
                     services => services.AddDirectoryBrowser());
                 var response = await server.CreateRequest(requestUrl).GetAsync();
@@ -117,7 +125,7 @@ namespace Microsoft.AspNetCore.StaticFiles
                             FileProvider = fileProvider
                         });
 
-                        app.UseEndpoints(endpoints => {});
+                        app.UseEndpoints(endpoints => { });
                     },
                     services => { services.AddDirectoryBrowser(); services.AddRouting(); });
 
@@ -133,9 +141,19 @@ namespace Microsoft.AspNetCore.StaticFiles
         [InlineData("/somedir", @".", "/somedir/")]
         [InlineData("/somedir", @"./", "/somedir/")]
         [InlineData("/somedir", @".", "/somedir/SubFolder/")]
-        public async Task FoundDirectory_Served_All(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("", @".", "/", false)]
+        [InlineData("", @".", "/SubFolder/", false)]
+        [InlineData("/somedir", @".", "/somedir/", false)]
+        [InlineData("/somedir", @"./", "/somedir/", false)]
+        [InlineData("/somedir", @".", "/somedir/SubFolder/", false)]
+        [InlineData("", @".", "", false)]
+        [InlineData("", @".", "/SubFolder", false)]
+        [InlineData("/somedir", @".", "/somedir", false)]
+        [InlineData("/somedir", @"./", "/somedir", false)]
+        [InlineData("/somedir", @".", "/somedir/SubFolder", false)]
+        public async Task FoundDirectory_Served_All(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await FoundDirectory_Served(baseUrl, baseDir, requestUrl);
+            await FoundDirectory_Served(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
         [ConditionalTheory]
@@ -143,12 +161,16 @@ namespace Microsoft.AspNetCore.StaticFiles
         [OSSkipCondition(OperatingSystems.MacOSX)]
         [InlineData("/somedir", @".\", "/somedir/")]
         [InlineData("/somedir", @".", "/somedir/subFolder/")]
-        public async Task FoundDirectory_Served_Windows(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("/somedir", @".\", "/somedir/", false)]
+        [InlineData("/somedir", @".", "/somedir/subFolder/", false)]
+        [InlineData("/somedir", @".\", "/somedir", false)]
+        [InlineData("/somedir", @".", "/somedir/subFolder", false)]
+        public async Task FoundDirectory_Served_Windows(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await FoundDirectory_Served(baseUrl, baseDir, requestUrl);
+            await FoundDirectory_Served(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
-        private async Task FoundDirectory_Served(string baseUrl, string baseDir, string requestUrl)
+        private async Task FoundDirectory_Served(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
             using (var fileProvider = new PhysicalFileProvider(Path.Combine(AppContext.BaseDirectory, baseDir)))
             {
@@ -156,7 +178,8 @@ namespace Microsoft.AspNetCore.StaticFiles
                     app => app.UseDirectoryBrowser(new DirectoryBrowserOptions
                     {
                         RequestPath = new PathString(baseUrl),
-                        FileProvider = fileProvider
+                        FileProvider = fileProvider,
+                        RedirectToAppendTrailingSlash = appendTrailingSlash,
                     }),
                     services => services.AddDirectoryBrowser());
                 var response = await server.CreateRequest(requestUrl).GetAsync();
@@ -215,21 +238,31 @@ namespace Microsoft.AspNetCore.StaticFiles
         [InlineData("", @".", "/SubFolder/")]
         [InlineData("/somedir", @".", "/somedir/")]
         [InlineData("/somedir", @".", "/somedir/SubFolder/")]
-        public async Task PostDirectory_PassesThrough_All(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("", @".", "/", false)]
+        [InlineData("", @".", "/SubFolder/", false)]
+        [InlineData("/somedir", @".", "/somedir/", false)]
+        [InlineData("/somedir", @".", "/somedir/SubFolder/", false)]
+        [InlineData("", @".", "", false)]
+        [InlineData("", @".", "/SubFolder", false)]
+        [InlineData("/somedir", @".", "/somedir", false)]
+        [InlineData("/somedir", @".", "/somedir/SubFolder", false)]
+        public async Task PostDirectory_PassesThrough_All(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await PostDirectory_PassesThrough(baseUrl, baseDir, requestUrl);
+            await PostDirectory_PassesThrough(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
         [ConditionalTheory]
         [OSSkipCondition(OperatingSystems.Linux)]
         [OSSkipCondition(OperatingSystems.MacOSX)]
         [InlineData("/somedir", @".", "/somedir/subFolder/")]
-        public async Task PostDirectory_PassesThrough_Windows(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("/somedir", @".", "/somedir/subFolder/", false)]
+        [InlineData("/somedir", @".", "/somedir/subFolder", false)]
+        public async Task PostDirectory_PassesThrough_Windows(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await PostDirectory_PassesThrough(baseUrl, baseDir, requestUrl);
+            await PostDirectory_PassesThrough(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
-        private async Task PostDirectory_PassesThrough(string baseUrl, string baseDir, string requestUrl)
+        private async Task PostDirectory_PassesThrough(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
             using (var fileProvider = new PhysicalFileProvider(Path.Combine(AppContext.BaseDirectory, baseDir)))
             {
@@ -237,7 +270,8 @@ namespace Microsoft.AspNetCore.StaticFiles
                     app => app.UseDirectoryBrowser(new DirectoryBrowserOptions
                     {
                         RequestPath = new PathString(baseUrl),
-                        FileProvider = fileProvider
+                        FileProvider = fileProvider,
+                        RedirectToAppendTrailingSlash = appendTrailingSlash
                     }),
                     services => services.AddDirectoryBrowser());
 
@@ -251,21 +285,31 @@ namespace Microsoft.AspNetCore.StaticFiles
         [InlineData("", @".", "/SubFolder/")]
         [InlineData("/somedir", @".", "/somedir/")]
         [InlineData("/somedir", @".", "/somedir/SubFolder/")]
-        public async Task HeadDirectory_HeadersButNotBodyServed_All(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("", @".", "/", false)]
+        [InlineData("", @".", "/SubFolder/", false)]
+        [InlineData("/somedir", @".", "/somedir/", false)]
+        [InlineData("/somedir", @".", "/somedir/SubFolder/", false)]
+        [InlineData("", @".", "", false)]
+        [InlineData("", @".", "/SubFolder", false)]
+        [InlineData("/somedir", @".", "/somedir", false)]
+        [InlineData("/somedir", @".", "/somedir/SubFolder", false)]
+        public async Task HeadDirectory_HeadersButNotBodyServed_All(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await HeadDirectory_HeadersButNotBodyServed(baseUrl, baseDir, requestUrl);
+            await HeadDirectory_HeadersButNotBodyServed(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
         [ConditionalTheory]
         [OSSkipCondition(OperatingSystems.Linux)]
         [OSSkipCondition(OperatingSystems.MacOSX)]
         [InlineData("/somedir", @".", "/somedir/subFolder/")]
-        public async Task HeadDirectory_HeadersButNotBodyServed_Windows(string baseUrl, string baseDir, string requestUrl)
+        [InlineData("/somedir", @".", "/somedir/subFolder/", false)]
+        [InlineData("/somedir", @".", "/somedir/subFolder", false)]
+        public async Task HeadDirectory_HeadersButNotBodyServed_Windows(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
-            await HeadDirectory_HeadersButNotBodyServed(baseUrl, baseDir, requestUrl);
+            await HeadDirectory_HeadersButNotBodyServed(baseUrl, baseDir, requestUrl, appendTrailingSlash);
         }
 
-        private async Task HeadDirectory_HeadersButNotBodyServed(string baseUrl, string baseDir, string requestUrl)
+        private async Task HeadDirectory_HeadersButNotBodyServed(string baseUrl, string baseDir, string requestUrl, bool appendTrailingSlash = true)
         {
             using (var fileProvider = new PhysicalFileProvider(Path.Combine(AppContext.BaseDirectory, baseDir)))
             {
@@ -273,7 +317,8 @@ namespace Microsoft.AspNetCore.StaticFiles
                     app => app.UseDirectoryBrowser(new DirectoryBrowserOptions
                     {
                         RequestPath = new PathString(baseUrl),
-                        FileProvider = fileProvider
+                        FileProvider = fileProvider,
+                        RedirectToAppendTrailingSlash = appendTrailingSlash
                     }),
                     services => services.AddDirectoryBrowser());
 
@@ -285,5 +330,11 @@ namespace Microsoft.AspNetCore.StaticFiles
                 Assert.Empty((await response.Content.ReadAsByteArrayAsync()));
             }
         }
+
+        [Fact]
+        public void Options_AppendTrailingSlashByDefault()
+        {
+            Assert.True(new DirectoryBrowserOptions().RedirectToAppendTrailingSlash);
+        }
     }
 }