From f970bbad3c5624a4458ff79069d9c765eea252a4 Mon Sep 17 00:00:00 2001 From: YDKK Date: Tue, 20 Oct 2026 20:17:50 +0900 Subject: [PATCH 1/4] =?UTF-8?q?.NET=2010=E3=81=B8=E7=A7=BB=E8=A1=8C?= =?UTF-8?q?=E3=81=97=E4=BE=9D=E5=AD=98=E3=83=91=E3=83=83=E3=82=B1=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit アプリ、Dockerイメージ、プルリクエストのCIを.NET 10に揃え、依存パッケージを更新する。 --- .github/workflows/pull_request.yml | 2 +- SlackLineBridge/Dockerfile | 6 +++--- SlackLineBridge/SlackLineBridge.csproj | 11 +++++------ 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 78700a1..bfa958e 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -12,6 +12,6 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v4 with: - dotnet-version: 8 + dotnet-version: 10.0.x - name: Build with dotnet run: dotnet build --configuration Release diff --git a/SlackLineBridge/Dockerfile b/SlackLineBridge/Dockerfile index 40be44e..effb1bd 100644 --- a/SlackLineBridge/Dockerfile +++ b/SlackLineBridge/Dockerfile @@ -1,8 +1,8 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app EXPOSE 80 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY ["SlackLineBridge/SlackLineBridge.csproj", "SlackLineBridge/"] RUN dotnet restore "SlackLineBridge/SlackLineBridge.csproj" @@ -16,4 +16,4 @@ RUN dotnet publish "SlackLineBridge.csproj" -c Release -o /app/publish FROM base AS final WORKDIR /app COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "SlackLineBridge.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "SlackLineBridge.dll"] diff --git a/SlackLineBridge/SlackLineBridge.csproj b/SlackLineBridge/SlackLineBridge.csproj index fc067e5..f72eb7f 100644 --- a/SlackLineBridge/SlackLineBridge.csproj +++ b/SlackLineBridge/SlackLineBridge.csproj @@ -1,16 +1,15 @@  - net8.0 + net10.0 Linux - - - - - + + + + From 87a8b0e28b98d99cb69ef7c8c1ac313277b93b07 Mon Sep 17 00:00:00 2001 From: YDKK Date: Tue, 20 Oct 2026 20:33:23 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E3=83=97=E3=83=AD=E3=82=AD=E3=82=B7?= =?UTF-8?q?=E3=83=A1=E3=83=87=E3=82=A3=E3=82=A2=E3=82=92S3=E4=BA=92?= =?UTF-8?q?=E6=8F=9B=E3=82=B9=E3=83=88=E3=83=AC=E3=83=BC=E3=82=B8=E3=81=AB?= =?UTF-8?q?=E3=82=AD=E3=83=A3=E3=83=83=E3=82=B7=E3=83=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit メディアキャッシュの設定と保存先を追加し、Rangeリクエストに対応する。キャッシュとプロキシのテストを追加し、CIでテストを実行する。 --- .github/workflows/pull_request.yml | 2 + README.md | 25 + .../MediaProxyServiceTests.cs | 476 ++++++++++++++++++ .../S3MediaCacheStoreTests.cs | 296 +++++++++++ .../SlackLineBridge.Tests.csproj | 24 + SlackLineBridge.sln | 6 + .../Controllers/ProxyController.cs | 76 ++- .../Configurations/MediaCacheOptions.cs | 71 +++ .../Services/MediaCacheS3ClientFactory.cs | 36 ++ SlackLineBridge/Services/MediaCacheStore.cs | 63 +++ SlackLineBridge/Services/MediaProxyService.cs | 342 +++++++++++++ SlackLineBridge/Services/S3MediaCacheStore.cs | 159 ++++++ SlackLineBridge/SlackLineBridge.csproj | 1 + SlackLineBridge/Startup.cs | 20 + SlackLineBridge/config.json.example | 12 +- 15 files changed, 1591 insertions(+), 18 deletions(-) create mode 100644 SlackLineBridge.Tests/MediaProxyServiceTests.cs create mode 100644 SlackLineBridge.Tests/S3MediaCacheStoreTests.cs create mode 100644 SlackLineBridge.Tests/SlackLineBridge.Tests.csproj create mode 100644 SlackLineBridge/Models/Configurations/MediaCacheOptions.cs create mode 100644 SlackLineBridge/Services/MediaCacheS3ClientFactory.cs create mode 100644 SlackLineBridge/Services/MediaCacheStore.cs create mode 100644 SlackLineBridge/Services/MediaProxyService.cs create mode 100644 SlackLineBridge/Services/S3MediaCacheStore.cs diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index bfa958e..d0a9f37 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -15,3 +15,5 @@ jobs: dotnet-version: 10.0.x - name: Build with dotnet run: dotnet build --configuration Release + - name: Run tests + run: dotnet test --configuration Release --no-build diff --git a/README.md b/README.md index c32e19b..69b2b58 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,31 @@ Amazon CloudWatch Logsを使う場合,AWSの資格情報を指定すること } ``` +### S3互換メディアキャッシュ(任意) + +LINE/Slackの署名を検証した後、取得済みメディアをprivateなS3互換バケットに保存します。`config.json` の `MediaCache` で有効化します。未設定または `Enabled: false` では従来どおり上流から配信します。 + +```jsonc +{ + "MediaCache": { + "Enabled": true, + "Endpoint": "https://s3.example.com", // AWS S3では空欄 + "Region": "us-east-1", + "Bucket": "private-media-cache", + "KeyPrefix": "slack-line-bridge/media", + "ForcePathStyle": true, // MinIOなどで必要な場合 + "AccessKey": "", // 両方空欄ならAWS SDK標準のcredential chainを使用 + "SecretKey": "" + } +} +``` + +有効時は `Region` と `Bucket` が必須です。明示資格情報を使う場合はAccessKey/SecretKeyを両方設定します。資格情報には対象prefix内の `s3:GetObject` と `s3:PutObject`、およびバケットの `s3:ListBucket` を許可してください。AWSではListBucket権限がないと不存在キーが403になる場合があり、このキャッシュは403をmissとして扱いません。バケットは公開せず、AWS側のBlock Public Accessも有効にしてください。保存キーにはprovider名とメディアID/URLのSHA-256だけを使います。 + +画像・動画・その他のファイルをMIME typeに関係なく保存します。オブジェクトの自動削除は行わないため、必要ならバケットのLifecycleルールで保持期間を設定してください。miss時は上流の全データを一時ファイルへ保存してからS3へ登録するため、OSの一時領域には同時取得中のファイル合計を置ける空き容量が必要です。メモリには固定サイズのbufferで転送します。 + +S3の読み書きに失敗した場合はキャッシュmissとして扱わずHTTP 503を返します。上流HTTPエラーのstatus codeは保持し、エラー本文はキャッシュしません。 + ## スクリーンショット Slack側 diff --git a/SlackLineBridge.Tests/MediaProxyServiceTests.cs b/SlackLineBridge.Tests/MediaProxyServiceTests.cs new file mode 100644 index 0000000..57a7cc6 --- /dev/null +++ b/SlackLineBridge.Tests/MediaProxyServiceTests.cs @@ -0,0 +1,476 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SlackLineBridge.Controllers; +using SlackLineBridge.Models.Configurations; +using SlackLineBridge.Services; +using SlackLineBridge.Utils; +using Xunit; + +namespace SlackLineBridge.Tests; + +public sealed class MediaProxyServiceTests +{ + private const string UpstreamUrl = "https://media.example.test/object"; + + [Fact] + public async Task FirstRequestStoresEveryMimeTypeAndSecondRequestUsesCache() + { + var body = new byte[] { 0, 1, 2, 3, 4, 5 }; + var store = new FakeMediaCacheStore(); + var handler = new StaticHandler(_ => Task.FromResult(Response(body, "application/pdf"))); + using var client = new HttpClient(handler); + var service = CreateService(store); + + var first = await service.GetAsync(client, UpstreamUrl, MediaCacheSource.Slack, UpstreamUrl, null, false, CancellationToken.None); + Assert.Equal((int)HttpStatusCode.OK, first.StatusCode); + Assert.Equal("application/pdf", first.ContentType); + Assert.Equal(body.Length, first.ContentLength); + Assert.True(first.EnableRangeProcessing); + Assert.Equal(body, await ReadAllAsync(first.Content)); + await first.Content.DisposeAsync(); + + var second = await service.GetAsync(client, UpstreamUrl, MediaCacheSource.Slack, UpstreamUrl, null, false, CancellationToken.None); + Assert.Equal((int)HttpStatusCode.OK, second.StatusCode); + Assert.Equal("application/pdf", second.ContentType); + Assert.Equal(body, await ReadAllAsync(second.Content)); + await second.Content.DisposeAsync(); + + Assert.Equal(1, handler.RequestCount); + Assert.Equal(1, store.PutCount); + Assert.Single(store.Keys.Distinct()); + } + + [Fact] + public async Task InvalidLineSignatureDoesNotAccessCacheOrUpstream() + { + var store = new FakeMediaCacheStore(); + var handler = new StaticHandler(_ => Task.FromResult(Response([1], "image/png"))); + using var client = new HttpClient(handler); + var service = CreateService(store); + var controller = new ProxyController( + NullLogger.Instance, + new TestHttpClientFactory(client), + service, + new LineChannelSecret("line-secret"), + new SlackSigningSecret("slack-secret")); + + var result = await controller.Line("message-id", "invalid-signature"); + + Assert.Equal((int)HttpStatusCode.Forbidden, Assert.IsType(result).StatusCode); + Assert.Equal(0, store.GetCount); + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public void CacheKeysSeparateProvidersAndDoNotContainIdentity() + { + const string identity = "https://media.example.test/private?token=secret"; + + var lineKey = MediaCacheKeys.Create("custom/prefix/", MediaCacheSource.Line, identity); + var slackKey = MediaCacheKeys.Create("custom/prefix/", MediaCacheSource.Slack, identity); + + Assert.NotEqual(lineKey, slackKey); + Assert.StartsWith("custom/prefix/line/", lineKey); + Assert.StartsWith("custom/prefix/slack/", slackKey); + Assert.DoesNotContain(identity, lineKey); + Assert.DoesNotContain("secret", slackKey); + Assert.EndsWith(lineKey.Split('/').Last(), slackKey); + } + + [Fact] + public void CacheConfigurationIsValidatedOnlyWhenEnabled() + { + Assert.Empty(new MediaCacheOptions { Enabled = false }.GetValidationErrors()); + + var errors = new MediaCacheOptions { Enabled = true }.GetValidationErrors(); + + Assert.Contains(errors, error => error.Contains("MediaCache:Region", StringComparison.Ordinal)); + Assert.Contains(errors, error => error.Contains("MediaCache:Bucket", StringComparison.Ordinal)); + } + + [Fact] + public async Task MvcReturnsCorrectRangeHeadersForMissAndHitAndFullContentOnUnrangedHit() + { + var body = new byte[] { 10, 11, 12, 13, 14, 15 }; + var store = new FakeMediaCacheStore(); + var handler = new StaticHandler(_ => Task.FromResult(Response(body, "video/mp4"))); + using var client = new HttpClient(handler); + var service = CreateService(store); + var mvcServices = new ServiceCollection().AddLogging().AddMvcCore().Services.BuildServiceProvider(); + + var miss = await ExecuteLineAsync(mvcServices, client, service, "bytes=1-3"); + Assert.True(Assert.IsType(miss.Result).EnableRangeProcessing); + Assert.Equal("bytes=1-3", miss.Context.Request.Headers["Range"].ToString()); + Assert.Equal((int)HttpStatusCode.PartialContent, miss.Context.Response.StatusCode); + Assert.Equal("bytes 1-3/6", miss.Context.Response.Headers["Content-Range"].ToString()); + Assert.Equal("bytes", miss.Context.Response.Headers["Accept-Ranges"].ToString()); + Assert.Equal(3, miss.Context.Response.ContentLength); + Assert.Equal(new byte[] { 11, 12, 13 }, ((MemoryStream)miss.Context.Response.Body).ToArray()); + + var hit = await ExecuteLineAsync(mvcServices, client, service, "bytes=1-3"); + Assert.Equal((int)HttpStatusCode.PartialContent, hit.Context.Response.StatusCode); + Assert.Equal("bytes 1-3/6", hit.Context.Response.Headers["Content-Range"].ToString()); + Assert.Equal("bytes", hit.Context.Response.Headers["Accept-Ranges"].ToString()); + Assert.Equal(3, hit.Context.Response.ContentLength); + Assert.Equal(new byte[] { 11, 12, 13 }, ((MemoryStream)hit.Context.Response.Body).ToArray()); + + var fullHit = await ExecuteLineAsync(mvcServices, client, service, null); + Assert.Equal((int)HttpStatusCode.OK, fullHit.Context.Response.StatusCode); + Assert.Equal(6, fullHit.Context.Response.ContentLength); + Assert.Equal("bytes", fullHit.Context.Response.Headers["Accept-Ranges"].ToString()); + Assert.False(fullHit.Context.Response.Headers.ContainsKey("Content-Range")); + Assert.Equal(body, ((MemoryStream)fullHit.Context.Response.Body).ToArray()); + + var unsatisfied = await ExecuteLineAsync(mvcServices, client, service, "bytes=99-100"); + Assert.Equal((int)HttpStatusCode.RequestedRangeNotSatisfiable, unsatisfied.Context.Response.StatusCode); + Assert.Equal("bytes */6", unsatisfied.Context.Response.Headers["Content-Range"].ToString()); + Assert.Equal("bytes", unsatisfied.Context.Response.Headers["Accept-Ranges"].ToString()); + Assert.Equal(0, unsatisfied.Context.Response.ContentLength); + Assert.Equal(1, handler.RequestCount); + + mvcServices.Dispose(); + } + + [Fact] + public async Task CacheHitUsesSingleByteRangeFromS3Object() + { + var body = new byte[] { 10, 11, 12, 13, 14, 15 }; + var store = new FakeMediaCacheStore(); + var handler = new StaticHandler(_ => Task.FromResult(Response(body, "video/mp4"))); + using var client = new HttpClient(handler); + var service = CreateService(store); + + var miss = await service.GetAsync(client, UpstreamUrl, MediaCacheSource.Line, "line-id", "bytes=1-3", false, CancellationToken.None); + Assert.Equal(body, await ReadAllAsync(miss.Content)); + await miss.Content.DisposeAsync(); + + var hit = await service.GetAsync(client, UpstreamUrl, MediaCacheSource.Line, "line-id", "bytes=1-3", false, CancellationToken.None); + Assert.Equal((int)HttpStatusCode.PartialContent, hit.StatusCode); + Assert.Equal("bytes 1-3/6", hit.ContentRange); + Assert.Equal(3, hit.ContentLength); + Assert.Equal(new byte[] { 11, 12, 13 }, await ReadAllAsync(hit.Content)); + await hit.Content.DisposeAsync(); + Assert.Equal(1, handler.RequestCount); + } + + [Fact] + public async Task FailedPutDeletesTemporaryFileAndDoesNotReturnSuccess() + { + var store = new FakeMediaCacheStore { FailPut = true }; + var handler = new StaticHandler(_ => Task.FromResult(Response([1, 2, 3], "application/octet-stream"))); + using var client = new HttpClient(handler); + var service = CreateService(store); + var before = GetTempFiles(); + + await Assert.ThrowsAsync(() => service.GetAsync( + client, UpstreamUrl, MediaCacheSource.Slack, UpstreamUrl, null, false, CancellationToken.None)); + + Assert.Equal(before, GetTempFiles()); + Assert.Equal(0, store.StoredObjectCount); + } + + [Fact] + public async Task AbortedUpstreamReadDeletesTemporaryFile() + { + var store = new FakeMediaCacheStore(); + var blockingStream = new BlockingReadStream(); + var handler = new StaticHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(blockingStream) + })); + using var client = new HttpClient(handler); + var service = CreateService(store); + var before = GetTempFiles(); + using var cancellation = new CancellationTokenSource(); + + var request = service.GetAsync(client, UpstreamUrl, MediaCacheSource.Line, "line-id", null, false, cancellation.Token); + await blockingStream.ReadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => request); + Assert.Equal(before, GetTempFiles()); + Assert.Equal(0, store.StoredObjectCount); + } + + [Fact] + public async Task UpstreamErrorStatusIsKeptAndErrorBodyIsNotCached() + { + var store = new FakeMediaCacheStore(); + var handler = new StaticHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("private upstream error") + })); + using var client = new HttpClient(handler); + var service = CreateService(store); + + var response = await service.GetAsync(client, UpstreamUrl, MediaCacheSource.Line, "line-id", null, false, CancellationToken.None); + + Assert.Equal((int)HttpStatusCode.NotFound, response.StatusCode); + Assert.Null(response.Content); + Assert.Equal(0, store.PutCount); + } + + [Fact] + public async Task DisabledCacheDoesNotAccessStorage() + { + var store = new FakeMediaCacheStore(); + var body = new byte[] { 4, 5, 6 }; + var handler = new StaticHandler(_ => Task.FromResult(Response(body, "image/jpeg"))); + using var client = new HttpClient(handler); + var service = CreateService(store, enabled: false); + + var response = await service.GetAsync(client, UpstreamUrl, MediaCacheSource.Line, "line-id", null, false, CancellationToken.None); + + Assert.Equal(body, await ReadAllAsync(response.Content)); + await response.Content.DisposeAsync(); + Assert.Equal(0, store.GetCount); + Assert.Equal(0, store.PutCount); + Assert.Equal(1, handler.RequestCount); + } + + [Fact] + public async Task StorageFailureReturns503WithoutCallingUpstream() + { + var store = new FakeMediaCacheStore { FailGet = true }; + var handler = new StaticHandler(_ => Task.FromResult(Response([1], "image/png"))); + using var client = new HttpClient(handler); + var service = CreateService(store); + var mvcServices = new ServiceCollection().AddLogging().AddMvcCore().Services.BuildServiceProvider(); + + var response = await ExecuteLineAsync(mvcServices, client, service, null); + + Assert.Equal((int)HttpStatusCode.ServiceUnavailable, response.Context.Response.StatusCode); + Assert.Equal(0, handler.RequestCount); + mvcServices.Dispose(); + } + + [Fact] + public async Task ConcurrentMissesForSameObjectOnlyFetchAndStoreOnce() + { + var store = new FakeMediaCacheStore(); + var fetchStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFetchToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handler = new StaticHandler(async _ => + { + fetchStarted.TrySetResult(); + await allowFetchToFinish.Task; + return Response([7, 8, 9], "application/pdf"); + }); + using var client = new HttpClient(handler); + var service = CreateService(store); + + var first = service.GetAsync(client, UpstreamUrl, MediaCacheSource.Slack, UpstreamUrl, null, false, CancellationToken.None); + await fetchStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var second = service.GetAsync(client, UpstreamUrl, MediaCacheSource.Slack, UpstreamUrl, null, false, CancellationToken.None); + allowFetchToFinish.TrySetResult(); + var results = await Task.WhenAll(first, second); + + Assert.Equal(1, handler.RequestCount); + Assert.Equal(1, store.PutCount); + foreach (var result in results) + { + Assert.Equal(new byte[] { 7, 8, 9 }, await ReadAllAsync(result.Content)); + await result.Content.DisposeAsync(); + } + } + + private static MediaProxyService CreateService(FakeMediaCacheStore store, bool enabled = true) + { + return new MediaProxyService(store, Options.Create(new MediaCacheOptions + { + Enabled = enabled, + KeyPrefix = "test-cache" + })); + } + + private static HttpResponseMessage Response(byte[] body, string contentType) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(body) + { + Headers = { ContentType = new MediaTypeHeaderValue(contentType) } + } + }; + } + + private static async Task ReadAllAsync(Stream stream) + { + await using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer); + return buffer.ToArray(); + } + + private static string[] GetTempFiles() + { + return Directory.GetFiles(Path.GetTempPath(), "slacklinebridge-mediacache-*.tmp").OrderBy(path => path).ToArray(); + } + + private static async Task<(IActionResult Result, DefaultHttpContext Context)> ExecuteLineAsync( + IServiceProvider services, + HttpClient client, + IMediaProxyService mediaProxyService, + string? range) + { + var context = new DefaultHttpContext + { + RequestServices = services, + Response = { Body = new MemoryStream() } + }; + context.Request.Method = "GET"; + if (range != null) + { + context.Request.Headers["Range"] = range; + } + + var controller = new ProxyController( + NullLogger.Instance, + new TestHttpClientFactory(client), + mediaProxyService, + new LineChannelSecret("line-secret"), + new SlackSigningSecret("slack-secret")); + controller.ControllerContext = new ControllerContext { HttpContext = context }; + var token = Crypt.GetHMACHex("line-id", "line-secret"); + var result = await controller.Line("line-id", token); + var actionContext = new ActionContext(context, new RouteData(), new ActionDescriptor()); + await result.ExecuteResultAsync(actionContext); + return (result, context); + } + + private sealed class FakeMediaCacheStore : IMediaCacheStore + { + private readonly ConcurrentDictionary _items = new(); + + public int GetCount { get; private set; } + public int PutCount { get; private set; } + public bool FailPut { get; init; } + public bool FailGet { get; init; } + public int StoredObjectCount => _items.Count; + public System.Collections.Generic.List Keys { get; } = []; + + public Task GetAsync(string key, string singleByteRange, CancellationToken cancellationToken) + { + GetCount++; + Keys.Add(key); + cancellationToken.ThrowIfCancellationRequested(); + if (FailGet) + { + throw new MediaCacheStorageException("Fake read failure."); + } + + if (!_items.TryGetValue(key, out var stored)) + { + return Task.FromResult(null!); + } + + if (singleByteRange == null) + { + return Task.FromResult(new MediaCacheObject(new MemoryStream(stored.Body, writable: false), stored.ContentType, stored.Body.Length)); + } + + var interval = singleByteRange["bytes=".Length..].Split('-'); + long from; + long to; + if (interval[0].Length == 0) + { + var suffixLength = long.Parse(interval[1]); + from = Math.Max(0, stored.Body.Length - suffixLength); + to = stored.Body.Length - 1; + } + else + { + from = long.Parse(interval[0]); + to = interval[1].Length == 0 + ? stored.Body.Length - 1 + : Math.Min(long.Parse(interval[1]), stored.Body.Length - 1); + } + + if (from >= stored.Body.Length) + { + throw new MediaCacheRangeNotSatisfiableException(stored.Body.Length); + } + + var length = checked((int)(to - from + 1)); + var body = stored.Body.Skip((int)from).Take(length).ToArray(); + return Task.FromResult(new MediaCacheObject( + new MemoryStream(body, writable: false), + stored.ContentType, + body.Length, + $"bytes {from}-{to}/{stored.Body.Length}")); + } + + public async Task PutAsync(string key, Stream content, string contentType, long contentLength, CancellationToken cancellationToken) + { + PutCount++; + if (FailPut) + { + throw new MediaCacheStorageException("Fake write failure."); + } + + await using var copy = new MemoryStream(); + await content.CopyToAsync(copy, cancellationToken); + _items[key] = new StoredMedia(copy.ToArray(), contentType); + } + + private sealed record StoredMedia(byte[] Body, string ContentType); + } + + private sealed class StaticHandler(Func> responseFactory) : HttpMessageHandler + { + public int RequestCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestCount++; + return responseFactory(request); + } + } + + private sealed class TestHttpClientFactory(HttpClient client) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => client; + } + + private sealed class BlockingReadStream : Stream + { + private readonly TaskCompletionSource _readStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource ReadStarted => _readStarted; + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + _readStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + } +} diff --git a/SlackLineBridge.Tests/S3MediaCacheStoreTests.cs b/SlackLineBridge.Tests/S3MediaCacheStoreTests.cs new file mode 100644 index 0000000..25da544 --- /dev/null +++ b/SlackLineBridge.Tests/S3MediaCacheStoreTests.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Amazon.S3; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SlackLineBridge.Models.Configurations; +using SlackLineBridge.Services; +using Xunit; + +namespace SlackLineBridge.Tests; + +public sealed class S3MediaCacheStoreTests +{ + [Fact] + public async Task AwsSdkV4UsesS3CompatibleWireOperationsForPutGetMissingAndRange() + { + await using var server = await S3Stub.StartAsync("private-bucket"); + using var client = MediaCacheS3ClientFactory.Create(new MediaCacheOptions + { + Enabled = true, + Endpoint = server.Endpoint, + Region = "ap-northeast-1", + Bucket = "private-bucket", + ForcePathStyle = true, + AccessKey = "test-access-key", + SecretKey = "test-secret-key" + }); + var store = new S3MediaCacheStore(client, "private-bucket"); + var key = "slack-line-bridge/media/slack/0123456789abcdef"; + var body = new byte[] { 1, 2, 3, 4, 5, 6 }; + + Assert.Null(await store.GetAsync("missing-object", null, CancellationToken.None)); + + await using (var source = new MemoryStream(body, writable: false)) + { + await store.PutAsync(key, source, "video/mp4", body.Length, CancellationToken.None); + } + + var full = Assert.IsType(await store.GetAsync(key, null, CancellationToken.None)); + Assert.Equal("video/mp4", full.ContentType); + Assert.Equal(body.Length, full.ContentLength); + Assert.Equal(body, await ReadAllAsync(full.Content)); + await full.Content.DisposeAsync(); + + var range = Assert.IsType(await store.GetAsync(key, "bytes=1-3", CancellationToken.None)); + Assert.Equal("bytes 1-3/6", range.ContentRange); + Assert.Equal(3, range.ContentLength); + Assert.Equal(new byte[] { 2, 3, 4 }, await ReadAllAsync(range.Content)); + await range.Content.DisposeAsync(); + + var openRange = Assert.IsType(await store.GetAsync(key, "bytes=2-", CancellationToken.None)); + Assert.Equal("bytes 2-5/6", openRange.ContentRange); + Assert.Equal(new byte[] { 3, 4, 5, 6 }, await ReadAllAsync(openRange.Content)); + await openRange.Content.DisposeAsync(); + + var suffixRange = Assert.IsType(await store.GetAsync(key, "bytes=-2", CancellationToken.None)); + Assert.Equal("bytes 4-5/6", suffixRange.ContentRange); + Assert.Equal(new byte[] { 5, 6 }, await ReadAllAsync(suffixRange.Content)); + await suffixRange.Content.DisposeAsync(); + + var invalidRange = await Assert.ThrowsAsync( + () => store.GetAsync(key, "bytes=99-100", CancellationToken.None)); + Assert.Equal(6, invalidRange.ContentLength); + + Assert.True(server.SawSignedRequest); + Assert.EndsWith("/ap-northeast-1/s3/aws4_request", server.CredentialScope); + Assert.Equal(body, server.Objects[key].Body); + Assert.Equal("video/mp4", server.Objects[key].ContentType); + Assert.False(server.SawOptionalChecksumHeaders); + } + + [Fact] + public async Task NoSuchBucketIsAStorageFailureAndNeverACacheMiss() + { + await using var server = await S3Stub.StartAsync("another-bucket"); + using var client = MediaCacheS3ClientFactory.Create(new MediaCacheOptions + { + Enabled = true, + Endpoint = server.Endpoint, + Region = "us-east-1", + Bucket = "missing-bucket", + ForcePathStyle = true, + AccessKey = "test-access-key", + SecretKey = "test-secret-key" + }); + var store = new S3MediaCacheStore(client, "missing-bucket"); + + await Assert.ThrowsAsync(() => store.GetAsync("any-object", null, CancellationToken.None)); + } + + private static async Task ReadAllAsync(Stream stream) + { + await using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer); + return buffer.ToArray(); + } + + private sealed class S3Stub : IAsyncDisposable + { + private readonly WebApplication _app; + private readonly string _bucket; + + private S3Stub(WebApplication app, string bucket) + { + _app = app; + _bucket = bucket; + } + + public string Endpoint { get; private set; } = string.Empty; + public ConcurrentDictionary Objects { get; } = new(StringComparer.Ordinal); + public bool SawSignedRequest { get; private set; } + public bool SawOptionalChecksumHeaders { get; private set; } + public string CredentialScope { get; private set; } = string.Empty; + + public static async Task StartAsync(string bucket) + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + var app = builder.Build(); + var stub = new S3Stub(app, bucket); + app.Run(stub.HandleAsync); + await app.StartAsync(); + + var addressesFeature = app.Services.GetRequiredService() + .Features.Get() + ?? throw new InvalidOperationException("The S3 stub has no bound address."); + var addresses = addressesFeature.Addresses; + stub.Endpoint = addresses.Single(address => address.StartsWith("http://", StringComparison.Ordinal)); + return stub; + } + + private async Task HandleAsync(HttpContext context) + { + var authorization = context.Request.Headers["Authorization"].ToString(); + SawSignedRequest |= authorization.StartsWith("AWS4-HMAC-SHA256 ", StringComparison.Ordinal); + var credentialStart = authorization.IndexOf("Credential=", StringComparison.Ordinal); + if (credentialStart >= 0) + { + var scopeStart = credentialStart + "Credential=".Length; + var scopeEnd = authorization.IndexOf(',', scopeStart); + var credential = scopeEnd >= 0 + ? authorization[scopeStart..scopeEnd] + : authorization[scopeStart..]; + var credentialParts = credential.Split('/'); + if (credentialParts.Length == 5) + { + CredentialScope = string.Join('/', credentialParts.Skip(1)); + } + } + + if (context.Request.Headers.Keys.Any(header => header.StartsWith("x-amz-sdk-checksum-algorithm", StringComparison.OrdinalIgnoreCase) + || header.StartsWith("x-amz-checksum-", StringComparison.OrdinalIgnoreCase))) + { + SawOptionalChecksumHeaders = true; + } + + var path = Uri.UnescapeDataString(context.Request.Path.Value ?? string.Empty).Trim('/'); + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length < 2 || segments[0] != _bucket) + { + await WriteS3ErrorAsync(context, StatusCodes.Status404NotFound, "NoSuchBucket"); + return; + } + + var key = string.Join('/', segments.Skip(1)); + if (HttpMethods.IsPut(context.Request.Method)) + { + await using var data = new MemoryStream(); + await context.Request.Body.CopyToAsync(data, context.RequestAborted); + Objects[key] = new StubObject(data.ToArray(), context.Request.ContentType ?? "application/octet-stream"); + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentLength = 0; + return; + } + + if (HttpMethods.IsHead(context.Request.Method)) + { + if (!Objects.TryGetValue(key, out var headObject)) + { + await WriteS3ErrorAsync(context, StatusCodes.Status404NotFound, "NoSuchKey"); + return; + } + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = headObject.ContentType; + context.Response.ContentLength = headObject.Body.Length; + return; + } + + if (!HttpMethods.IsGet(context.Request.Method) || !Objects.TryGetValue(key, out var stored)) + { + await WriteS3ErrorAsync(context, StatusCodes.Status404NotFound, "NoSuchKey"); + return; + } + + var rangeHeader = context.Request.Headers["Range"].ToString(); + if (string.IsNullOrEmpty(rangeHeader)) + { + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = stored.ContentType; + context.Response.ContentLength = stored.Body.Length; + await context.Response.Body.WriteAsync(stored.Body, context.RequestAborted); + return; + } + + if (!TryGetRange(rangeHeader, stored.Body.Length, out var start, out var end)) + { + await WriteS3ErrorAsync(context, StatusCodes.Status416RangeNotSatisfiable, "InvalidRange"); + return; + } + + var rangeContent = stored.Body.AsMemory((int)start, checked((int)(end - start + 1))); + context.Response.StatusCode = StatusCodes.Status206PartialContent; + context.Response.ContentType = stored.ContentType; + context.Response.Headers["Content-Range"] = $"bytes {start}-{end}/{stored.Body.Length}"; + context.Response.ContentLength = rangeContent.Length; + await context.Response.Body.WriteAsync(rangeContent, context.RequestAborted); + } + + private static bool TryGetRange(string value, int length, out long start, out long end) + { + start = 0; + end = 0; + if (!value.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var terms = value[6..].Split('-'); + if (terms.Length != 2) + { + return false; + } + + if (terms[0].Length == 0) + { + if (!long.TryParse(terms[1], out var suffixLength) || suffixLength <= 0 || length == 0) + { + return false; + } + + start = Math.Max(0, length - suffixLength); + end = length - 1; + return true; + } + + if (!long.TryParse(terms[0], out start) || start < 0 || start >= length) + { + return false; + } + + if (terms[1].Length == 0) + { + end = length - 1; + return true; + } + + if (!long.TryParse(terms[1], out end) || end < start) + { + return false; + } + + end = Math.Min(end, length - 1); + return true; + } + + private static async Task WriteS3ErrorAsync(HttpContext context, int statusCode, string code) + { + var body = Encoding.UTF8.GetBytes($"{code}Stubbed S3 response.local"); + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/xml"; + context.Response.ContentLength = body.Length; + await context.Response.Body.WriteAsync(body, context.RequestAborted); + } + + public async ValueTask DisposeAsync() + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + + public sealed record StubObject(byte[] Body, string ContentType); + } +} diff --git a/SlackLineBridge.Tests/SlackLineBridge.Tests.csproj b/SlackLineBridge.Tests/SlackLineBridge.Tests.csproj new file mode 100644 index 0000000..075b55b --- /dev/null +++ b/SlackLineBridge.Tests/SlackLineBridge.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + false + true + enable + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/SlackLineBridge.sln b/SlackLineBridge.sln index 4376a4e..7f86471 100644 --- a/SlackLineBridge.sln +++ b/SlackLineBridge.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.11.35222.181 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlackLineBridge", "SlackLineBridge\SlackLineBridge.csproj", "{20565EFE-65C3-4EB1-8E09-7C9A9324BB66}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlackLineBridge.Tests", "SlackLineBridge.Tests\SlackLineBridge.Tests.csproj", "{1E45355C-7D3C-4B87-AC4A-07B35DA9DF1D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {20565EFE-65C3-4EB1-8E09-7C9A9324BB66}.Debug|Any CPU.Build.0 = Debug|Any CPU {20565EFE-65C3-4EB1-8E09-7C9A9324BB66}.Release|Any CPU.ActiveCfg = Release|Any CPU {20565EFE-65C3-4EB1-8E09-7C9A9324BB66}.Release|Any CPU.Build.0 = Release|Any CPU + {1E45355C-7D3C-4B87-AC4A-07B35DA9DF1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E45355C-7D3C-4B87-AC4A-07B35DA9DF1D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E45355C-7D3C-4B87-AC4A-07B35DA9DF1D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E45355C-7D3C-4B87-AC4A-07B35DA9DF1D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/SlackLineBridge/Controllers/ProxyController.cs b/SlackLineBridge/Controllers/ProxyController.cs index b03c9f0..381530a 100644 --- a/SlackLineBridge/Controllers/ProxyController.cs +++ b/SlackLineBridge/Controllers/ProxyController.cs @@ -1,11 +1,7 @@ using System; -using System.Collections.Generic; using System.IO; -using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Mime; -using System.Text; using System.Threading.Tasks; using System.Web; using Microsoft.AspNetCore.Http; @@ -13,6 +9,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; using SlackLineBridge.Models.Configurations; +using SlackLineBridge.Services; using SlackLineBridge.Utils; namespace SlackLineBridge.Controllers @@ -22,6 +19,7 @@ namespace SlackLineBridge.Controllers public class ProxyController( ILogger logger, IHttpClientFactory clientFactory, + IMediaProxyService mediaProxyService, LineChannelSecret lineChannelSecret, SlackSigningSecret slackSigningSecret ) : ControllerBase @@ -40,14 +38,12 @@ public async Task Line(string id, string token) var client = clientFactory.CreateClient("Line"); var url = $"https://api-data.line.me/v2/bot/message/{id}/content"; - return await ProxyContent(client, url); + return await ProxyContent(client, url, MediaCacheSource.Line, id); } [HttpGet("slack/{token}/{encodedUrl}")] public async Task Slack(string encodedUrl, string token) { - logger.LogInformation("Proxy request to Slack: {encodedUrl}, {token}", encodedUrl, token); - var url = HttpUtility.UrlDecode(encodedUrl); if (token != Crypt.GetHMACHex(url, _slackSigningSecret)) { @@ -56,23 +52,69 @@ public async Task Slack(string encodedUrl, string token) var client = clientFactory.CreateClient("Slack"); - return await ProxyContent(client, url); + return await ProxyContent(client, url, MediaCacheSource.Slack, url); } - private static async Task ProxyContent(HttpClient client, string url) + private async Task ProxyContent(HttpClient client, string url, MediaCacheSource source, string identity) { - var result = await client.GetAsync(url); - if (result.IsSuccessStatusCode) + MediaProxyResponse result; + try + { + result = await mediaProxyService.GetAsync( + client, + url, + source, + identity, + Request.Headers[HeaderNames.Range].ToString(), + Request.Headers.ContainsKey("If-Range"), + Request.HttpContext.RequestAborted); + } + catch (MediaCacheStorageException) + { + logger.LogError("Media cache storage operation failed."); + return StatusCode((int)HttpStatusCode.ServiceUnavailable); + } + catch (HttpRequestException) + { + logger.LogWarning("Upstream media request failed."); + return StatusCode((int)HttpStatusCode.BadGateway); + } + catch (IOException) + { + logger.LogWarning("Upstream media stream failed."); + return StatusCode((int)HttpStatusCode.BadGateway); + } + catch (TaskCanceledException) when (!Request.HttpContext.RequestAborted.IsCancellationRequested) + { + logger.LogWarning("Upstream media request timed out."); + return StatusCode((int)HttpStatusCode.GatewayTimeout); + } + + Response.StatusCode = result.StatusCode; + if (!result.EnableRangeProcessing && result.ContentLength.HasValue) + { + Response.ContentLength = result.ContentLength.Value; + } + + if (!string.IsNullOrEmpty(result.ContentRange)) { - var stream = await result.Content.ReadAsStreamAsync(); - var contentType = result.Content.Headers.GetValues("Content-Type").First(); + Response.Headers[HeaderNames.ContentRange] = result.ContentRange; + } - return new FileStreamResult(stream, contentType); + if (result.SupportsRangeRequests) + { + Response.Headers[HeaderNames.AcceptRanges] = "bytes"; } - else + + if (result.Content == null) { - return new StatusCodeResult((int)result.StatusCode); + return new StatusCodeResult(result.StatusCode); } + + return new FileStreamResult(result.Content, result.ContentType) + { + EnableRangeProcessing = result.EnableRangeProcessing + }; } } -} \ No newline at end of file +} diff --git a/SlackLineBridge/Models/Configurations/MediaCacheOptions.cs b/SlackLineBridge/Models/Configurations/MediaCacheOptions.cs new file mode 100644 index 0000000..b157b88 --- /dev/null +++ b/SlackLineBridge/Models/Configurations/MediaCacheOptions.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SlackLineBridge.Models.Configurations +{ + public sealed class MediaCacheOptions + { + public bool Enabled { get; set; } + + public string Endpoint { get; set; } + + public string Region { get; set; } + + public string Bucket { get; set; } + + public string KeyPrefix { get; set; } = "slack-line-bridge/media"; + + public bool ForcePathStyle { get; set; } + + public string AccessKey { get; set; } + + public string SecretKey { get; set; } + + public IReadOnlyList GetValidationErrors() + { + var errors = new List(); + if (!Enabled) + { + return errors; + } + + if (string.IsNullOrWhiteSpace(Region)) + { + errors.Add("MediaCache:Region is required when the media cache is enabled."); + } + + if (string.IsNullOrWhiteSpace(Bucket)) + { + errors.Add("MediaCache:Bucket is required when the media cache is enabled."); + } + else if (Bucket.Contains('/') || Bucket.Contains('\\') || Bucket.Any(char.IsWhiteSpace)) + { + errors.Add("MediaCache:Bucket must be a bucket name without path separators or whitespace."); + } + + if (!string.IsNullOrWhiteSpace(Endpoint) + && (!Uri.TryCreate(Endpoint, UriKind.Absolute, out var endpoint) + || (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps) + || !string.IsNullOrEmpty(endpoint.UserInfo) + || !string.IsNullOrEmpty(endpoint.Query) + || !string.IsNullOrEmpty(endpoint.Fragment))) + { + errors.Add("MediaCache:Endpoint must be an HTTP or HTTPS base URL without user info, query, or fragment."); + } + + if (string.IsNullOrWhiteSpace(AccessKey) != string.IsNullOrWhiteSpace(SecretKey)) + { + errors.Add("MediaCache:AccessKey and MediaCache:SecretKey must be set together."); + } + + if ((KeyPrefix ?? string.Empty).Split('/', StringSplitOptions.RemoveEmptyEntries) + .Any(segment => segment is "." or "..")) + { + errors.Add("MediaCache:KeyPrefix cannot contain dot path segments."); + } + + return errors; + } + } +} diff --git a/SlackLineBridge/Services/MediaCacheS3ClientFactory.cs b/SlackLineBridge/Services/MediaCacheS3ClientFactory.cs new file mode 100644 index 0000000..de63903 --- /dev/null +++ b/SlackLineBridge/Services/MediaCacheS3ClientFactory.cs @@ -0,0 +1,36 @@ +using Amazon; +using Amazon.Runtime; +using Amazon.S3; +using SlackLineBridge.Models.Configurations; + +namespace SlackLineBridge.Services +{ + public static class MediaCacheS3ClientFactory + { + public static IAmazonS3 Create(MediaCacheOptions options) + { + var s3Config = new AmazonS3Config + { + ForcePathStyle = options.ForcePathStyle, + RequestChecksumCalculation = RequestChecksumCalculation.WHEN_REQUIRED, + ResponseChecksumValidation = ResponseChecksumValidation.WHEN_REQUIRED + }; + if (!string.IsNullOrWhiteSpace(options.Endpoint)) + { + s3Config.ServiceURL = options.Endpoint.TrimEnd('/'); + s3Config.AuthenticationRegion = options.Region; + } + else + { + s3Config.RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region); + } + + if (!string.IsNullOrWhiteSpace(options.AccessKey)) + { + return new AmazonS3Client(new BasicAWSCredentials(options.AccessKey, options.SecretKey), s3Config); + } + + return new AmazonS3Client(s3Config); + } + } +} diff --git a/SlackLineBridge/Services/MediaCacheStore.cs b/SlackLineBridge/Services/MediaCacheStore.cs new file mode 100644 index 0000000..7ff618e --- /dev/null +++ b/SlackLineBridge/Services/MediaCacheStore.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace SlackLineBridge.Services +{ + public enum MediaCacheSource + { + Line, + Slack + } + + public sealed record MediaCacheObject(Stream Content, string ContentType, long ContentLength, string ContentRange = null); + + public interface IMediaCacheStore + { + Task GetAsync(string key, string singleByteRange, CancellationToken cancellationToken); + + Task PutAsync(string key, Stream content, string contentType, long contentLength, CancellationToken cancellationToken); + } + + public sealed class MediaCacheStorageException : Exception + { + public MediaCacheStorageException(string message, Exception innerException = null) + : base(message, innerException) + { + } + } + + public sealed class MediaCacheRangeNotSatisfiableException(long contentLength) : Exception + { + public long ContentLength { get; } = contentLength; + } + + public static class MediaCacheKeys + { + public static string Create(string keyPrefix, MediaCacheSource source, string identity) + { + var prefix = (keyPrefix ?? string.Empty).Trim('/'); + var provider = source.ToString().ToLowerInvariant(); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity ?? string.Empty))).ToLowerInvariant(); + return string.IsNullOrEmpty(prefix) + ? $"{provider}/{hash}" + : $"{prefix}/{provider}/{hash}"; + } + } + + public sealed class NoOpMediaCacheStore : IMediaCacheStore + { + public Task GetAsync(string key, string singleByteRange, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task PutAsync(string key, Stream content, string contentType, long contentLength, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/SlackLineBridge/Services/MediaProxyService.cs b/SlackLineBridge/Services/MediaProxyService.cs new file mode 100644 index 0000000..5120859 --- /dev/null +++ b/SlackLineBridge/Services/MediaProxyService.cs @@ -0,0 +1,342 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using SlackLineBridge.Models.Configurations; + +namespace SlackLineBridge.Services +{ + public sealed class MediaProxyResponse( + int statusCode, + Stream content = null, + string contentType = null, + long? contentLength = null, + string contentRange = null, + bool enableRangeProcessing = false, + bool supportsRangeRequests = false) + { + public int StatusCode { get; } = statusCode; + public Stream Content { get; } = content; + public string ContentType { get; } = contentType; + public long? ContentLength { get; } = contentLength; + public string ContentRange { get; } = contentRange; + public bool EnableRangeProcessing { get; } = enableRangeProcessing; + public bool SupportsRangeRequests { get; } = supportsRangeRequests; + } + + public sealed class MediaProxyService : IMediaProxyService + { + private const int BufferSize = 81920; + private const int LockStripeCount = 64; + + private readonly IMediaCacheStore _cacheStore; + private readonly MediaCacheOptions _options; + private readonly SemaphoreSlim[] _lockStripes; + + public MediaProxyService(IMediaCacheStore cacheStore, IOptions options) + { + _cacheStore = cacheStore; + _options = options.Value; + _lockStripes = Enumerable.Range(0, LockStripeCount).Select(_ => new SemaphoreSlim(1, 1)).ToArray(); + } + + public async Task GetAsync( + HttpClient client, + string url, + MediaCacheSource source, + string identity, + string rangeHeader, + bool hasIfRange, + CancellationToken cancellationToken) + { + if (!_options.Enabled) + { + return await GetFromUpstreamAsync(client, url, cancellationToken); + } + + var key = MediaCacheKeys.Create(_options.KeyPrefix, source, identity); + var requestedRange = hasIfRange ? null : GetSingleByteRange(rangeHeader); + var enableMissRangeProcessing = !hasIfRange + && (string.IsNullOrWhiteSpace(rangeHeader) || requestedRange != null); + MediaCacheObject cached; + try + { + cached = await _cacheStore.GetAsync(key, requestedRange, cancellationToken); + } + catch (MediaCacheRangeNotSatisfiableException exception) + { + return RangeNotSatisfiable(exception); + } + + if (cached != null) + { + return FromCached(cached, requestedRange != null); + } + + var stripe = GetLockStripe(key); + await _lockStripes[stripe].WaitAsync(cancellationToken); + try + { + try + { + cached = await _cacheStore.GetAsync(key, requestedRange, cancellationToken); + } + catch (MediaCacheRangeNotSatisfiableException exception) + { + return RangeNotSatisfiable(exception); + } + + if (cached != null) + { + return FromCached(cached, requestedRange != null); + } + + return await FetchAndCacheAsync(client, url, key, enableMissRangeProcessing, cancellationToken); + } + finally + { + _lockStripes[stripe].Release(); + } + } + + private async Task GetFromUpstreamAsync(HttpClient client, string url, CancellationToken cancellationToken) + { + HttpResponseMessage response = null; + try + { + response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return new MediaProxyResponse((int)response.StatusCode); + } + + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var contentType = GetContentType(response.Content.Headers.ContentType); + var contentLength = response.Content.Headers.ContentLength; + var statusCode = (int)response.StatusCode; + var ownedStream = new OwningStream(stream, response); + response = null; + return new MediaProxyResponse(statusCode, ownedStream, contentType, contentLength); + } + finally + { + response?.Dispose(); + } + } + + private async Task FetchAndCacheAsync( + HttpClient client, + string url, + string key, + bool enableRangeProcessing, + CancellationToken cancellationToken) + { + HttpResponseMessage response = null; + FileStream tempFile = null; + string tempPath = null; + try + { + response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return new MediaProxyResponse((int)response.StatusCode); + } + + if (response.StatusCode != HttpStatusCode.OK) + { + var upstreamStream = await response.Content.ReadAsStreamAsync(cancellationToken); + var contentType = GetContentType(response.Content.Headers.ContentType); + var contentLength = response.Content.Headers.ContentLength; + var statusCode = (int)response.StatusCode; + var passthrough = new OwningStream(upstreamStream, response); + response = null; + return new MediaProxyResponse(statusCode, passthrough, contentType, contentLength); + } + + var responseContentType = GetContentType(response.Content.Headers.ContentType); + tempPath = Path.Combine(Path.GetTempPath(), $"slacklinebridge-mediacache-{Guid.NewGuid():N}.tmp"); + try + { + tempFile = new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.None, + BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new MediaCacheStorageException("Could not create a media cache temporary file.", exception); + } + + await using (var upstreamStream = await response.Content.ReadAsStreamAsync(cancellationToken)) + { + await CopyToTempFileAsync(upstreamStream, tempFile, cancellationToken); + } + + try + { + await tempFile.FlushAsync(cancellationToken); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new MediaCacheStorageException("Could not flush a media cache temporary file.", exception); + } + + var contentLengthOnDisk = tempFile.Length; + tempFile.Position = 0; + await _cacheStore.PutAsync(key, tempFile, responseContentType, contentLengthOnDisk, cancellationToken); + + tempFile.Position = 0; + var resultStream = new OwningStream(tempFile, new DeleteFileOwner(tempPath)); + tempFile = null; + tempPath = null; + + return new MediaProxyResponse( + (int)HttpStatusCode.OK, + resultStream, + responseContentType, + contentLengthOnDisk, + enableRangeProcessing: enableRangeProcessing, + supportsRangeRequests: true); + } + finally + { + response?.Dispose(); + if (tempFile != null) + { + await tempFile.DisposeAsync(); + } + + if (tempPath != null) + { + TryDeleteFile(tempPath); + } + } + } + + private static MediaProxyResponse FromCached(MediaCacheObject cached, bool rangeRequested) + { + if (rangeRequested) + { + return new MediaProxyResponse( + (int)HttpStatusCode.PartialContent, + cached.Content, + cached.ContentType, + cached.ContentLength, + cached.ContentRange, + supportsRangeRequests: true); + } + + return new MediaProxyResponse( + (int)HttpStatusCode.OK, + cached.Content, + cached.ContentType, + cached.ContentLength, + supportsRangeRequests: true); + } + + private static MediaProxyResponse RangeNotSatisfiable(MediaCacheRangeNotSatisfiableException exception) + { + return new MediaProxyResponse( + (int)HttpStatusCode.RequestedRangeNotSatisfiable, + contentLength: 0, + contentRange: $"bytes */{exception.ContentLength}", + supportsRangeRequests: true); + } + + private static async Task CopyToTempFileAsync(Stream source, FileStream destination, CancellationToken cancellationToken) + { + var buffer = new byte[BufferSize]; + while (true) + { + var bytesRead = await source.ReadAsync(buffer.AsMemory(), cancellationToken); + if (bytesRead == 0) + { + return; + } + + try + { + await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new MediaCacheStorageException("Could not write a media cache temporary file.", exception); + } + } + } + + private static string GetSingleByteRange(string rangeHeader) + { + if (string.IsNullOrWhiteSpace(rangeHeader) + || !RangeHeaderValue.TryParse(rangeHeader, out var parsed) + || !string.Equals(parsed.Unit, "bytes", StringComparison.OrdinalIgnoreCase) + || parsed.Ranges.Count != 1) + { + return null; + } + + var range = parsed.Ranges.Single(); + if (range.From == null && range.To == null) + { + return null; + } + + return $"bytes={range}"; + } + + private static int GetLockStripe(string key) + { + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(key)); + var stripe = BitConverter.ToUInt32(digest, 0) % LockStripeCount; + return (int)stripe; + } + + private static string GetContentType(MediaTypeHeaderValue contentType) + { + return string.IsNullOrWhiteSpace(contentType?.ToString()) + ? "application/octet-stream" + : contentType.ToString(); + } + + private static void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + private sealed class DeleteFileOwner(string path) : IDisposable + { + public void Dispose() => TryDeleteFile(path); + } + } + + public interface IMediaProxyService + { + Task GetAsync( + HttpClient client, + string url, + MediaCacheSource source, + string identity, + string rangeHeader, + bool hasIfRange, + CancellationToken cancellationToken); + } +} diff --git a/SlackLineBridge/Services/S3MediaCacheStore.cs b/SlackLineBridge/Services/S3MediaCacheStore.cs new file mode 100644 index 0000000..04d0726 --- /dev/null +++ b/SlackLineBridge/Services/S3MediaCacheStore.cs @@ -0,0 +1,159 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; + +namespace SlackLineBridge.Services +{ + public sealed class S3MediaCacheStore(IAmazonS3 s3Client, string bucket) : IMediaCacheStore + { + public async Task GetAsync(string key, string singleByteRange, CancellationToken cancellationToken) + { + GetObjectResponse response; + try + { + var request = new GetObjectRequest + { + BucketName = bucket, + Key = key + }; + if (!string.IsNullOrEmpty(singleByteRange)) + { + request.ByteRange = new ByteRange(singleByteRange); + } + + response = await s3Client.GetObjectAsync(request, cancellationToken); + } + catch (AmazonS3Exception exception) when (string.Equals(exception.ErrorCode, "NoSuchKey", StringComparison.Ordinal)) + { + return null; + } + catch (AmazonS3Exception exception) when (IsRangeNotSatisfiable(exception)) + { + try + { + var metadata = await s3Client.GetObjectMetadataAsync(new GetObjectMetadataRequest + { + BucketName = bucket, + Key = key + }, cancellationToken); + throw new MediaCacheRangeNotSatisfiableException(metadata.ContentLength); + } + catch (MediaCacheRangeNotSatisfiableException) + { + throw; + } + catch (OperationCanceledException metadataException) when (!cancellationToken.IsCancellationRequested) + { + throw new MediaCacheStorageException("Media cache metadata read timed out.", metadataException); + } + catch (Exception metadataException) when (IsStorageFailure(metadataException)) + { + throw new MediaCacheStorageException("Media cache metadata read failed.", metadataException); + } + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new MediaCacheStorageException("Media cache read timed out.", exception); + } + catch (Exception exception) when (IsStorageFailure(exception)) + { + throw new MediaCacheStorageException("Media cache read failed.", exception); + } + + if (!string.IsNullOrEmpty(singleByteRange) && string.IsNullOrEmpty(response.ContentRange)) + { + response.Dispose(); + throw new MediaCacheStorageException("The object store did not return the requested byte range."); + } + + var contentType = string.IsNullOrWhiteSpace(response.Headers.ContentType) + ? "application/octet-stream" + : response.Headers.ContentType; + return new MediaCacheObject( + new OwningStream(response.ResponseStream, response), + contentType, + response.ContentLength, + response.ContentRange); + } + + public async Task PutAsync(string key, Stream content, string contentType, long contentLength, CancellationToken cancellationToken) + { + try + { + var request = new PutObjectRequest + { + BucketName = bucket, + Key = key, + InputStream = content, + ContentType = contentType, + AutoCloseStream = false, + AutoResetStreamPosition = false, + UseChunkEncoding = false + }; + request.Headers.ContentLength = contentLength; + await s3Client.PutObjectAsync(request, cancellationToken); + } + catch (Exception exception) when (IsStorageFailure(exception)) + { + throw new MediaCacheStorageException("Media cache write failed.", exception); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new MediaCacheStorageException("Media cache write timed out.", exception); + } + } + + private static bool IsStorageFailure(Exception exception) + { + return exception is AmazonServiceException + or AmazonClientException + or HttpRequestException + or IOException; + } + + private static bool IsRangeNotSatisfiable(AmazonS3Exception exception) + { + return string.Equals(exception.ErrorCode, "InvalidRange", StringComparison.Ordinal) + || exception.StatusCode == System.Net.HttpStatusCode.RequestedRangeNotSatisfiable; + } + } + + internal sealed class OwningStream(Stream inner, IDisposable owner) : Stream + { + public override bool CanRead => inner.CanRead; + public override bool CanSeek => inner.CanSeek; + public override bool CanWrite => inner.CanWrite; + public override long Length => inner.Length; + public override long Position { get => inner.Position; set => inner.Position = value; } + public override void Flush() => inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); + public override int Read(Span buffer) => inner.Read(buffer); + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => inner.ReadAsync(buffer, cancellationToken); + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => inner.ReadAsync(buffer, offset, count, cancellationToken); + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + public override void SetLength(long value) => inner.SetLength(value); + public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + try + { + inner.Dispose(); + } + finally + { + owner.Dispose(); + } + } + + base.Dispose(disposing); + } + } +} diff --git a/SlackLineBridge/SlackLineBridge.csproj b/SlackLineBridge/SlackLineBridge.csproj index f72eb7f..0d8a276 100644 --- a/SlackLineBridge/SlackLineBridge.csproj +++ b/SlackLineBridge/SlackLineBridge.csproj @@ -7,6 +7,7 @@ + diff --git a/SlackLineBridge/Startup.cs b/SlackLineBridge/Startup.cs index 60025ce..e7051c9 100644 --- a/SlackLineBridge/Startup.cs +++ b/SlackLineBridge/Startup.cs @@ -7,6 +7,7 @@ using System.Text.Json.Serialization.Samples; using System.Threading.Tasks; using Amazon.Runtime; +using Amazon.S3; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; @@ -60,6 +61,25 @@ public void ConfigureServices(IServiceCollection services) services.Configure(x => x.Channels = Configuration.GetSection("slackChannels").Get()); services.Configure(x => x.Channels = Configuration.GetSection("lineChannels").Get()); services.Configure(x => x.Bridges = Configuration.GetSection("slackLineBridges").Get()); + var mediaCacheSection = Configuration.GetSection("MediaCache"); + services.AddOptions() + .Bind(mediaCacheSection) + .Validate(options => options.GetValidationErrors().Count == 0, "MediaCache configuration is invalid when Enabled is true.") + .ValidateOnStart(); + var mediaCacheOptions = mediaCacheSection.Get() ?? new MediaCacheOptions(); + if (mediaCacheOptions.Enabled) + { + services.AddSingleton(serviceProvider => MediaCacheS3ClientFactory.Create( + serviceProvider.GetRequiredService>().Value)); + services.AddSingleton(serviceProvider => new S3MediaCacheStore( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService>().Value.Bucket)); + } + else + { + services.AddSingleton(); + } + services.AddSingleton(); services.AddSingleton>(); //lineRequestQueue services.AddSingleton(new LineChannelSecret(Configuration["lineChannelSecret"])); services.AddSingleton(new SlackSigningSecret(Configuration["slackSigningSecret"])); diff --git a/SlackLineBridge/config.json.example b/SlackLineBridge/config.json.example index 248d3ba..6af2294 100644 --- a/SlackLineBridge/config.json.example +++ b/SlackLineBridge/config.json.example @@ -22,5 +22,15 @@ "lineAccessToken": "", "lineChannelSecret": "", "slackSigningSecret": "", - "slackAccessToken": "" + "slackAccessToken": "", + "MediaCache": { + "Enabled": false, + "Endpoint": "", + "Region": "us-east-1", + "Bucket": "", + "KeyPrefix": "slack-line-bridge/media", + "ForcePathStyle": false, + "AccessKey": "", + "SecretKey": "" + } } From c68243c36dd680b7c6f0eec7060d4742279ccaac Mon Sep 17 00:00:00 2001 From: YDKK Date: Tue, 20 Oct 2026 21:17:58 +0900 Subject: [PATCH 3/4] =?UTF-8?q?LINE=20Webhook=E3=82=92FIFO=E3=82=AD?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E3=81=A7=E5=87=A6=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 署名検証後にイベントを容量256件のキューへ登録して応答し、キュー満杯時や停止中は503を返す。停止時は受付済みイベントの処理完了を待ち、停止期限に達した場合は外部通信をキャンセルする。定期的な自己アクセス処理を削除する。 --- .../LineWebhookQueueTests.cs | 647 ++++++++++++++++++ .../Controllers/WebhookController.cs | 48 +- .../Services/BackgroundAccessingService.cs | 47 -- .../Services/LineMessageProcessingService.cs | 296 ++++---- SlackLineBridge/Services/LineWebhookQueue.cs | 47 ++ SlackLineBridge/Startup.cs | 4 +- SlackLineBridge/Utils/Crypt.cs | 10 +- 7 files changed, 905 insertions(+), 194 deletions(-) create mode 100644 SlackLineBridge.Tests/LineWebhookQueueTests.cs delete mode 100644 SlackLineBridge/Services/BackgroundAccessingService.cs create mode 100644 SlackLineBridge/Services/LineWebhookQueue.cs diff --git a/SlackLineBridge.Tests/LineWebhookQueueTests.cs b/SlackLineBridge.Tests/LineWebhookQueueTests.cs new file mode 100644 index 0000000..3797c95 --- /dev/null +++ b/SlackLineBridge.Tests/LineWebhookQueueTests.cs @@ -0,0 +1,647 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SlackLineBridge.Controllers; +using SlackLineBridge.Models.Configurations; +using SlackLineBridge.Services; +using SlackLineBridge.Utils; +using Xunit; + +namespace SlackLineBridge.Tests; + +public sealed class LineWebhookQueueTests +{ + private const string LineSecret = "test-line-channel-secret"; + + [Fact] + public async Task MissingAndInvalidSignaturesDoNotEnqueueRequests() + { + var queue = new LineWebhookQueue(4); + var missing = CreateController(queue, "{}"); + var missingResult = await missing.LineAsync(); + Assert.IsType(missingResult); + + var invalid = CreateController(queue, "{}", "not-a-valid-signature"); + var invalidResult = await invalid.LineAsync(); + Assert.IsType(invalidResult); + + var marker = QueueRequest("marker", "test-host"); + Assert.True(queue.TryEnqueue(marker)); + Assert.Equal(marker, await ReadNextAsync(queue)); + } + + [Fact] + public async Task ValidSignatureIsEnqueuedBeforeHttpOkIsReturned() + { + const string body = "{\"events\":[]}"; + var queue = new LineWebhookQueue(4); + var controller = CreateController(queue, body, Crypt.GetHMACBase64(body, LineSecret)); + + var result = await controller.LineAsync(); + + Assert.IsType(result); + var request = await ReadNextAsync(queue); + Assert.Equal(body, Encoding.UTF8.GetString(request.Body)); + Assert.Equal("bridge.example", request.Host); + } + + [Fact] + public async Task SignatureValidationUsesExactRawRequestBytes() + { + var json = Encoding.UTF8.GetBytes("{\"events\":[]}"); + var body = new byte[] { 0xEF, 0xBB, 0xBF }.Concat(json).ToArray(); + var signature = Convert.ToBase64String(Crypt.CalcHMAC(body, LineSecret)); + var queue = new LineWebhookQueue(4); + var controller = CreateController(queue, body, signature); + + var result = await controller.LineAsync(); + + Assert.IsType(result); + Assert.Equal(body, (await ReadNextAsync(queue)).Body); + } + + [Fact] + public async Task AbortedRequestBodyReadDoesNotEnqueue() + { + var queue = new LineWebhookQueue(4); + var body = Encoding.UTF8.GetBytes("{}"); + var controller = CreateController(queue, body, Convert.ToBase64String(Crypt.CalcHMAC(body, LineSecret))); + var blockingBody = new CancellationReadStream(); + controller.HttpContext.Request.Body = blockingBody; + using var aborted = new CancellationTokenSource(); + controller.HttpContext.RequestAborted = aborted.Token; + + var receiving = controller.LineAsync(); + await blockingBody.ReadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + aborted.Cancel(); + await Assert.ThrowsAnyAsync(() => receiving); + + var marker = QueueRequest("marker", "test-host"); + Assert.True(queue.TryEnqueue(marker)); + Assert.Equal(marker, await ReadNextAsync(queue)); + } + + [Fact] + public async Task HttpAcknowledgementDoesNotWaitForExternalProfileRequest() + { + var profileStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finishProfile = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handler = new DelegateHandler(async (request, cancellationToken) => + { + if (request.RequestUri!.AbsolutePath.Contains("/profile/", StringComparison.Ordinal)) + { + profileStarted.TrySetResult(); + await finishProfile.Task.WaitAsync(cancellationToken); + return JsonResponse(HttpStatusCode.OK, "{\"displayName\":\"Test User\"}"); + } + + return JsonResponse(HttpStatusCode.OK, "ok"); + }); + using var client = CreateClient(handler); + var factory = new TestHttpClientFactory(client); + var queue = new LineWebhookQueue(4); + var service = CreateProcessingService(queue, factory, NullLogger.Instance); + await service.StartAsync(CancellationToken.None); + + var body = MessageEvent(); + var controller = CreateController(queue, body, Crypt.GetHMACBase64(body, LineSecret), factory, service); + var result = await controller.LineAsync().WaitAsync(TimeSpan.FromSeconds(5)); + await profileStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.IsType(result); + finishProfile.TrySetResult(); + await StopAsync(service); + } + + [Fact] + public async Task KestrelPipelineAcknowledgesBeforeProcessingAndRejectsInvalidOrFullQueue() + { + var profileStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finishFirstProfile = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondSlackPost = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var slackBodies = new ConcurrentQueue(); + var profileRequests = 0; + var handler = new DelegateHandler(async (request, cancellationToken) => + { + if (request.RequestUri!.AbsolutePath.Contains("/profile/", StringComparison.Ordinal)) + { + if (Interlocked.Increment(ref profileRequests) == 1) + { + profileStarted.TrySetResult(); + await finishFirstProfile.Task.WaitAsync(cancellationToken); + } + return JsonResponse(HttpStatusCode.OK, "{\"displayName\":\"Test User\"}"); + } + + slackBodies.Enqueue(await request.Content!.ReadAsStringAsync(cancellationToken)); + if (slackBodies.Count == 2) + { + secondSlackPost.TrySetResult(); + } + return JsonResponse(HttpStatusCode.OK, "ok"); + }); + using var outboundClient = CreateClient(handler); + var clientFactory = new TestHttpClientFactory(outboundClient); + + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + builder.Services.AddSingleton(new LineWebhookQueue(1)); + builder.Services.AddSingleton(new LineChannelSecret(LineSecret)); + builder.Services.AddSingleton(new SlackSigningSecret("test-slack-signing-secret")); + builder.Services.AddSingleton(new JsonSerializerOptions()); + builder.Services.AddSingleton(clientFactory); + builder.Services.Configure(options => options.Channels = CreateSlackChannels().Channels); + builder.Services.Configure(options => options.Channels = CreateLineChannels().Channels); + builder.Services.Configure(options => options.Bridges = CreateBridges().Bridges); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(provider => provider.GetRequiredService()); + builder.Services.AddControllers().AddApplicationPart(typeof(WebhookController).Assembly); + await using var app = builder.Build(); + app.MapControllers(); + await app.StartAsync(); + + try + { + var addresses = app.Services.GetRequiredService() + .Features.Get()! + .Addresses; + var endpoint = addresses.Single(address => address.StartsWith("http://", StringComparison.Ordinal)); + using var httpClient = new HttpClient(); + + var firstBody = MessageEvent("first-raw-body-marker"); + using var firstResponse = await PostWebhookAsync(httpClient, endpoint, firstBody, sign: true).WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); + await profileStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(finishFirstProfile.Task.IsCompleted); + + var queuedBody = MessageEvent("second-raw-body-marker"); + using var queuedResponse = await PostWebhookAsync(httpClient, endpoint, queuedBody, sign: true).WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(HttpStatusCode.OK, queuedResponse.StatusCode); + + using var invalidResponse = await PostWebhookAsync(httpClient, endpoint, MessageEvent("invalid"), sign: false).WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(HttpStatusCode.BadRequest, invalidResponse.StatusCode); + + using var fullResponse = await PostWebhookAsync(httpClient, endpoint, MessageEvent("queue-full"), sign: true).WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(HttpStatusCode.ServiceUnavailable, fullResponse.StatusCode); + + finishFirstProfile.TrySetResult(); + await secondSlackPost.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await app.StopAsync(); + + Assert.Contains(slackBodies, body => body.Contains("first-raw-body-marker", StringComparison.Ordinal)); + Assert.Contains(slackBodies, body => body.Contains("second-raw-body-marker", StringComparison.Ordinal)); + Assert.Equal(2, slackBodies.Count); + } + finally + { + finishFirstProfile.TrySetResult(); + } + } + + [Fact] + public async Task ConsumerProcessesConsecutiveRequestsWithoutPollingDelay() + { + var secondSlackPost = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var slackPosts = 0; + var handler = new DelegateHandler((request, _) => + { + if (request.RequestUri!.AbsolutePath.Contains("/profile/", StringComparison.Ordinal)) + { + return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{\"displayName\":\"Test User\"}")); + } + + if (Interlocked.Increment(ref slackPosts) == 2) + { + secondSlackPost.TrySetResult(); + } + return Task.FromResult(JsonResponse(HttpStatusCode.OK, "ok")); + }); + using var client = CreateClient(handler); + var queue = new LineWebhookQueue(4); + Assert.True(queue.TryEnqueue(QueueRequest(MessageEvent("first"), "bridge.example"))); + Assert.True(queue.TryEnqueue(QueueRequest(MessageEvent("second"), "bridge.example"))); + var service = CreateProcessingService(queue, new TestHttpClientFactory(client), NullLogger.Instance); + + await service.StartAsync(CancellationToken.None); + await secondSlackPost.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await StopAsync(service); + + Assert.Equal(2, slackPosts); + } + + [Fact] + public async Task MalformedAndFailedRequestsDoNotStopTheNextRequestAndLogsOmitSecrets() + { + var profileRequests = 0; + var slackAttempts = 0; + var successfulSlackPosts = 0; + var handler = new DelegateHandler((request, _) => + { + if (request.RequestUri!.AbsolutePath.Contains("/profile/", StringComparison.Ordinal)) + { + Interlocked.Increment(ref profileRequests); + return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{\"displayName\":\"Test User\"}")); + } + + if (Interlocked.Increment(ref slackAttempts) == 1) + { + throw new HttpRequestException("sensitive webhook URL should not be logged"); + } + + Interlocked.Increment(ref successfulSlackPosts); + return Task.FromResult(JsonResponse(HttpStatusCode.OK, "ok")); + }); + using var client = CreateClient(handler); + var queue = new LineWebhookQueue(4); + Assert.True(queue.TryEnqueue(QueueRequest("malformed-private-message", "private-host"))); + Assert.True(queue.TryEnqueue(QueueRequest(MessageEvent("private-message"), "private-host"))); + Assert.True(queue.TryEnqueue(QueueRequest(MessageEvent("private-message"), "private-host"))); + using var provider = new TestLoggerProvider(); + using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(provider)); + var service = CreateProcessingService(queue, new TestHttpClientFactory(client), loggerFactory.CreateLogger()); + + await service.StartAsync(CancellationToken.None); + await StopAsync(service); + + Assert.Equal(2, profileRequests); + Assert.Equal(2, slackAttempts); + Assert.Equal(1, successfulSlackPosts); + var logs = string.Join("\n", provider.Messages); + Assert.DoesNotContain("malformed-private-message", logs, StringComparison.Ordinal); + Assert.DoesNotContain("private-message", logs, StringComparison.Ordinal); + Assert.DoesNotContain("private-host", logs, StringComparison.Ordinal); + Assert.DoesNotContain("sensitive webhook URL", logs, StringComparison.Ordinal); + Assert.DoesNotContain("slack-webhook-token", logs, StringComparison.Ordinal); + } + + [Fact] + public async Task NonSuccessProfileResponseUsesFallbackAndStillForwardsMessage() + { + var slackPosts = 0; + var handler = new DelegateHandler((request, _) => + { + if (request.RequestUri!.AbsolutePath.Contains("/profile/", StringComparison.Ordinal)) + { + return Task.FromResult(JsonResponse(HttpStatusCode.BadGateway, "upstream error")); + } + + Interlocked.Increment(ref slackPosts); + return Task.FromResult(JsonResponse(HttpStatusCode.OK, "ok")); + }); + using var client = CreateClient(handler); + var queue = new LineWebhookQueue(4); + Assert.True(queue.TryEnqueue(QueueRequest(MessageEvent(), "bridge.example"))); + using var provider = new TestLoggerProvider(); + using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(provider)); + var service = CreateProcessingService(queue, new TestHttpClientFactory(client), loggerFactory.CreateLogger()); + + await service.StartAsync(CancellationToken.None); + await StopAsync(service); + + Assert.Equal(1, slackPosts); + Assert.Contains(provider.Messages, message => message.Contains("BadGateway", StringComparison.Ordinal)); + } + + [Fact] + public async Task NonSuccessSlackResponseIsLoggedAsFailureAndNextRequestContinues() + { + var slackPosts = 0; + var secondSlackPost = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var provider = new TestLoggerProvider(); + using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(provider)); + var handler = new DelegateHandler((request, _) => + { + if (request.RequestUri!.AbsolutePath.Contains("/profile/", StringComparison.Ordinal)) + { + return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{\"displayName\":\"Test User\"}")); + } + + var count = Interlocked.Increment(ref slackPosts); + if (count == 2) + { + secondSlackPost.TrySetResult(); + } + return Task.FromResult(JsonResponse(count == 1 ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK, "ok")); + }); + using var client = CreateClient(handler); + var queue = new LineWebhookQueue(4); + Assert.True(queue.TryEnqueue(QueueRequest(MessageEvent("first"), "bridge.example"))); + Assert.True(queue.TryEnqueue(QueueRequest(MessageEvent("second"), "bridge.example"))); + var service = CreateProcessingService(queue, new TestHttpClientFactory(client), loggerFactory.CreateLogger()); + + await service.StartAsync(CancellationToken.None); + await secondSlackPost.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await StopAsync(service); + + Assert.Equal(2, slackPosts); + Assert.Contains(provider.Messages, message => message.Contains("ServiceUnavailable", StringComparison.Ordinal)); + Assert.Single(provider.Messages, message => message.Contains("Post to Slack returned status", StringComparison.Ordinal)); + } + + [Fact] + public async Task FullQueueReturnsServiceUnavailableWithoutWaiting() + { + var queue = new LineWebhookQueue(1); + var queued = QueueRequest("queued", "test-host"); + Assert.True(queue.TryEnqueue(queued)); + var body = "{\"events\":[]}"; + var controller = CreateController(queue, body, Crypt.GetHMACBase64(body, LineSecret)); + + var result = await controller.LineAsync(); + + var status = Assert.IsType(result); + Assert.Equal(StatusCodes.Status503ServiceUnavailable, status.StatusCode); + Assert.Equal(queued, await ReadNextAsync(queue)); + } + + [Fact] + public async Task GracefulStopDrainsAcceptedRequestsAndClosesQueue() + { + var slackPosts = 0; + var handler = new DelegateHandler((request, _) => + { + if (request.RequestUri!.AbsolutePath.Contains("/profile/", StringComparison.Ordinal)) + { + return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{\"displayName\":\"Test User\"}")); + } + Interlocked.Increment(ref slackPosts); + return Task.FromResult(JsonResponse(HttpStatusCode.OK, "ok")); + }); + using var client = CreateClient(handler); + var queue = new LineWebhookQueue(4); + Assert.True(queue.TryEnqueue(QueueRequest(MessageEvent(), "bridge.example"))); + var service = CreateProcessingService(queue, new TestHttpClientFactory(client), NullLogger.Instance); + await service.StartAsync(CancellationToken.None); + + await StopAsync(service); + + Assert.Equal(1, slackPosts); + Assert.False(queue.TryEnqueue(QueueRequest("after-stop", "bridge.example"))); + } + + [Fact] + public async Task ShutdownDeadlineCancelsActiveExternalRequest() + { + var profileStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var profileCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handler = new DelegateHandler(async (request, cancellationToken) => + { + if (!request.RequestUri!.AbsolutePath.Contains("/profile/", StringComparison.Ordinal)) + { + return JsonResponse(HttpStatusCode.OK, "ok"); + } + + profileStarted.TrySetResult(); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) + { + profileCancelled.TrySetResult(); + throw; + } + return JsonResponse(HttpStatusCode.OK, "{\"displayName\":\"Test User\"}"); + }); + using var client = CreateClient(handler); + var queue = new LineWebhookQueue(4); + Assert.True(queue.TryEnqueue(QueueRequest(MessageEvent(), "bridge.example"))); + var service = CreateProcessingService(queue, new TestHttpClientFactory(client), NullLogger.Instance); + await service.StartAsync(CancellationToken.None); + await profileStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + using var shutdownDeadline = new CancellationTokenSource(); + var stopping = service.StopAsync(shutdownDeadline.Token); + shutdownDeadline.Cancel(); + await stopping; + await profileCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + if (service.ExecuteTask is { } executeTask) + { + await executeTask.WaitAsync(TimeSpan.FromSeconds(5)); + } + } + + private static WebhookController CreateController( + LineWebhookQueue queue, + string body, + string? signature = null, + TestHttpClientFactory? clientFactory = null, + LineMessageProcessingService? processingService = null) + { + return CreateController(queue, Encoding.UTF8.GetBytes(body), signature, clientFactory, processingService); + } + + private static WebhookController CreateController( + LineWebhookQueue queue, + byte[] body, + string? signature, + TestHttpClientFactory? clientFactory = null, + LineMessageProcessingService? processingService = null) + { + var channels = CreateLineChannels(); + var bridges = CreateBridges(); + var slackChannels = CreateSlackChannels(); + var factory = clientFactory ?? new TestHttpClientFactory(CreateClient(new DelegateHandler((_, _) => + Task.FromResult(JsonResponse(HttpStatusCode.OK, "ok"))))); + processingService ??= CreateProcessingService(queue, factory, NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Request.Body = new System.IO.MemoryStream(body, writable: false); + context.Request.Host = new HostString("bridge.example"); + if (signature != null) + { + context.Request.Headers["X-Line-Signature"] = signature; + } + + var controller = new WebhookController( + NullLogger.Instance, + new TestOptions(slackChannels), + new TestOptions(channels), + new TestOptions(bridges), + processingService, + queue, + factory, + new LineChannelSecret(LineSecret), + new SlackSigningSecret("test-slack-signing-secret"), + new JsonSerializerOptions()); + controller.ControllerContext = new ControllerContext { HttpContext = context }; + return controller; + } + + private static LineMessageProcessingService CreateProcessingService( + LineWebhookQueue queue, + IHttpClientFactory clientFactory, + ILogger logger) + { + return new LineMessageProcessingService( + new TestOptions(CreateSlackChannels()), + new TestOptions(CreateLineChannels()), + new TestOptions(CreateBridges()), + clientFactory, + queue, + new LineChannelSecret(LineSecret), + logger); + } + + private static SlackChannels CreateSlackChannels() => new() + { + Channels = + [ + new SlackChannel + { + Name = "slack-channel", + ChannelId = "slack-channel-id", + WebhookUrl = "https://hooks.slack.test/services/slack-webhook-token" + } + ] + }; + + private static LineChannels CreateLineChannels() => new() + { + Channels = [new LineChannel { Name = "line-channel", Id = "user-123" }] + }; + + private static SlackLineBridges CreateBridges() => new() + { + Bridges = [new global::SlackLineBridge.Models.Configurations.SlackLineBridge { Slack = "slack-channel", Line = "line-channel" }] + }; + + private static string MessageEvent(string marker = "hello") => JsonSerializer.Serialize(new + { + events = new[] + { + new + { + type = "message", + replyToken = "reply-token", + source = new { type = "user", userId = "user-123" }, + message = new { type = "text", id = "message-id", text = marker } + } + } + }); + + private static LineWebhookRequest QueueRequest(string body, string host) => + new(Encoding.UTF8.GetBytes(body), host); + + private static async Task PostWebhookAsync(HttpClient client, string endpoint, string body, bool sign) + { + var bodyBytes = Encoding.UTF8.GetBytes(body); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{endpoint}/line") + { + Content = new ByteArrayContent(bodyBytes) + }; + request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + var signature = sign + ? Convert.ToBase64String(Crypt.CalcHMAC(bodyBytes, LineSecret)) + : "not-a-valid-signature"; + request.Headers.TryAddWithoutValidation("X-Line-Signature", signature); + return await client.SendAsync(request); + } + + private static async Task ReadNextAsync(LineWebhookQueue queue) + { + await using var reader = queue.ReadAllAsync(CancellationToken.None).GetAsyncEnumerator(); + Assert.True(await reader.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5))); + return reader.Current; + } + + private static async Task StopAsync(LineMessageProcessingService service) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await service.StopAsync(timeout.Token); + } + + private static HttpClient CreateClient(HttpMessageHandler handler) => new(handler) + { + BaseAddress = new Uri("https://api.line.test/v2/bot/") + }; + + private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, string body) => new(statusCode) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + + private sealed class TestHttpClientFactory(HttpClient client) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => client; + } + + private sealed class TestOptions(T value) : IOptionsSnapshot, IOptionsMonitor where T : class + { + public T Value => value; + public T CurrentValue => value; + public T Get(string? name) => value; + public IDisposable? OnChange(Action listener) => null; + } + + private sealed class DelegateHandler(Func> sendAsync) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return sendAsync(request, cancellationToken); + } + } + + private sealed class CancellationReadStream : Stream + { + public TaskCompletionSource ReadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ReadStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + } + + private sealed class TestLoggerProvider : ILoggerProvider + { + private readonly ConcurrentQueue _messages = new(); + public string[] Messages => _messages.ToArray(); + + public ILogger CreateLogger(string categoryName) => new TestLogger(_messages); + public void Dispose() { } + + private sealed class TestLogger(ConcurrentQueue messages) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + messages.Enqueue(formatter(state, exception)); + } + } + } +} diff --git a/SlackLineBridge/Controllers/WebhookController.cs b/SlackLineBridge/Controllers/WebhookController.cs index cdfcae3..d441076 100644 --- a/SlackLineBridge/Controllers/WebhookController.cs +++ b/SlackLineBridge/Controllers/WebhookController.cs @@ -1,10 +1,10 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Net.Http; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; @@ -29,8 +29,9 @@ public partial class WebhookController( IOptionsSnapshot lineChannels, IOptionsSnapshot bridges, LineMessageProcessingService lineMessageProcessingService, - ConcurrentQueue<(string signature, string body, string host)> lineRequestQueue, + LineWebhookQueue lineRequestQueue, IHttpClientFactory clientFactory, + LineChannelSecret lineChannelSecret, SlackSigningSecret slackSigningSecret, JsonSerializerOptions jsonOptions) : ControllerBase { @@ -38,7 +39,9 @@ public partial class WebhookController( private readonly LineChannels _lineChannels = lineChannels.Value; private readonly SlackLineBridges _bridges = bridges.Value; private readonly string _slackSigningSecret = slackSigningSecret.Secret; + private readonly string _lineChannelSecret = lineChannelSecret.Secret; private readonly LineMessageProcessingService _lineMessageProcessingService = lineMessageProcessingService; + private readonly LineWebhookQueue _lineRequestQueue = lineRequestQueue; [HttpPost("/slack2")] public async Task Slack2() @@ -240,31 +243,42 @@ private async Task PushToLine(string host, SlackChannel slackChan [HttpPost("/line")] public async Task LineAsync() { - if (!Request.Headers.TryGetValue("X-Line-Signature", out StringValues signature)) + if (!Request.Headers.TryGetValue("X-Line-Signature", out StringValues signature) || StringValues.IsNullOrEmpty(signature)) { - logger.LogInformation("X-Line-Signature header missing."); + logger.LogInformation("LINE webhook rejected because its signature header is missing."); + return BadRequest(); + } + using var bodyStream = new MemoryStream(); + await Request.Body.CopyToAsync(bodyStream, HttpContext.RequestAborted); + var body = bodyStream.ToArray(); + HttpContext.RequestAborted.ThrowIfCancellationRequested(); + if (!IsValidLineSignature(body, signature.ToString())) + { + logger.LogInformation("LINE webhook rejected because its signature is invalid."); return BadRequest(); } - string json = null; - string host = null; - try + HttpContext.RequestAborted.ThrowIfCancellationRequested(); + if (!_lineRequestQueue.TryEnqueue(new LineWebhookRequest(body, Request.Host.ToString()))) { - using var reader = new StreamReader(Request.Body); - json = await reader.ReadToEndAsync(); - host = Request.Host.ToString(); - return Ok(); + logger.LogWarning("LINE webhook queue is full or closed; rejecting request."); + return StatusCode(503); } - finally + + return Ok(); + } + + private bool IsValidLineSignature(byte[] body, string providedSignature) + { + Span providedHash = stackalloc byte[32]; + if (!Convert.TryFromBase64String(providedSignature, providedHash, out var bytesWritten) || bytesWritten != providedHash.Length) { - Response.OnCompleted(() => - { - lineRequestQueue.Enqueue((signature, json, host)); - return Task.CompletedTask; - }); + return false; } + var expectedHash = Crypt.CalcHMAC(body, _lineChannelSecret); + return CryptographicOperations.FixedTimeEquals(expectedHash, providedHash); } [HttpGet("/health")] diff --git a/SlackLineBridge/Services/BackgroundAccessingService.cs b/SlackLineBridge/Services/BackgroundAccessingService.cs deleted file mode 100644 index 08f0505..0000000 --- a/SlackLineBridge/Services/BackgroundAccessingService.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace SlackLineBridge.Services -{ - public class BackgroundAccessingService : BackgroundService - { - private readonly ILogger _logger; - private readonly IHttpClientFactory _clientFactory; - - public BackgroundAccessingService(ILogger logger, IHttpClientFactory clientFactory) - { - _logger = logger; - _clientFactory = clientFactory; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogDebug($"BackgroundAccessingService is starting."); - - stoppingToken.Register(() => _logger.LogDebug($" BackgroundAccessing background task is stopping.")); - - var client = _clientFactory.CreateClient(); - while (!stoppingToken.IsCancellationRequested) - { - try - { - var response = await client.PostAsync("http://localhost/line", new StringContent("")); - } - catch - { - // ignore - } - - await Task.Delay(1000 * 60, stoppingToken); - } - - _logger.LogDebug($"BackgroundAccessing background task is stopped."); - } - } -} diff --git a/SlackLineBridge/Services/LineMessageProcessingService.cs b/SlackLineBridge/Services/LineMessageProcessingService.cs index cf72a9f..f0ec1a2 100644 --- a/SlackLineBridge/Services/LineMessageProcessingService.cs +++ b/SlackLineBridge/Services/LineMessageProcessingService.cs @@ -7,6 +7,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Dynamic; +using System.IO; using System.Linq; using System.Net.Http; using System.Text; @@ -23,7 +24,7 @@ public class LineMessageProcessingService : BackgroundService private readonly IOptionsMonitor _bridges; private readonly IHttpClientFactory _clientFactory; private readonly ILogger _logger; - private readonly ConcurrentQueue<(string signature, string body, string host)> _queue; + private readonly LineWebhookQueue _queue; private readonly string _lineChannelSecret; // key: LINE channel id, value: last reply token private readonly ConcurrentDictionary _lastReplyTokens = []; @@ -33,7 +34,7 @@ public LineMessageProcessingService( IOptionsMonitor lineChannels, IOptionsMonitor bridges, IHttpClientFactory clientFactory, - ConcurrentQueue<(string signature, string body, string host)> lineRequestQueue, + LineWebhookQueue lineRequestQueue, LineChannelSecret lineChannelSecret, ILogger logger) { @@ -47,118 +48,144 @@ public LineMessageProcessingService( } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogDebug($"LineMessageProcessingService is starting."); - - stoppingToken.Register(() => _logger.LogDebug($" LineMessageProcessing background task is stopping.")); - - while (!stoppingToken.IsCancellationRequested) + _logger.LogDebug("LineMessageProcessingService is starting."); + try { - if (_queue.TryDequeue(out var request)) + await foreach (var request in _queue.ReadAllAsync(stoppingToken)) { - _logger.LogInformation("Processing request from line: " + request.body); - - var signature = Crypt.GetHMACBase64(request.body, _lineChannelSecret); - _logger.LogDebug($"LINE signature check (expected:{request.signature}, calculated:{signature})"); - if (request.signature != signature) + try { - _logger.LogInformation("LINE signature missmatch."); - continue; + await ProcessRequestAsync(request, stoppingToken); } - - var data = JsonSerializer.Deserialize(request.body); - - foreach (var e in data.GetProperty("events").EnumerateArray()) + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { - switch (e.GetProperty("type").GetString()) - { - case "message": - { - LineChannel lineChannel = GetLineChannel(e); - if (lineChannel == null) - { - _logger.LogInformation($"message from unknown line channel: {GetLineEventSourceId(e)}"); - continue; - } + break; + } + catch (Exception ex) + { + _logger.LogError("LINE webhook processing failed; request skipped ({ExceptionType}).", ex.GetType().Name); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // The host shutdown deadline expired while waiting for more work. + } + _logger.LogDebug("LineMessageProcessing background task is stopped."); + } - var bridges = GetBridges(lineChannel); - if (!bridges.Any()) - { - continue; - } - if (e.TryGetProperty("replyToken", out var replyTokenElement)) - { - var replyToken = replyTokenElement.GetString(); - if (!string.IsNullOrEmpty(replyToken)) - { - _lastReplyTokens.AddOrUpdate(lineChannel.Id, replyToken, (key, value) => replyToken); - } - } - var (userName, pictureUrl) = await GetLineProfileAsync(e); + public override async Task StopAsync(CancellationToken cancellationToken) + { + _queue.Complete(); - var message = e.GetProperty("message"); - var type = message.GetProperty("type").GetString(); - var text = ""; - string imageUrl = null; - var id = ""; - if (message.TryGetProperty("id", out var idElement)) - { - id = idElement.GetString(); - } + if (ExecuteTask is { } executeTask) + { + try + { + await executeTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogWarning("LINE webhook queue did not drain before the shutdown deadline; cancelling active work."); + await base.StopAsync(cancellationToken); + return; + } + } - switch (type) - { - case "text": - text = message.GetProperty("text").GetString(); - break; - case "sticker": - var stickerId = message.GetProperty("stickerId").GetString(); - imageUrl = $"https://stickershop.line-scdn.net/stickershop/v1/sticker/{stickerId}/android/sticker.png"; - break; - case "image": - imageUrl = $"https://{request.host}/proxy/line/{Crypt.GetHMACHex(id, _lineChannelSecret)}/{id}"; - break; - default: - text = $"<{type}>"; - if (!string.IsNullOrEmpty(id)) - { - text += $"\nhttps://{request.host}/proxy/line/{Crypt.GetHMACHex(id, _lineChannelSecret)}/{id}"; - } - break; - } + await base.StopAsync(cancellationToken); + } - foreach (var bridge in bridges) - { - var slackChannel = _slackChannels.CurrentValue.Channels.FirstOrDefault(x => x.Name == bridge.Slack); - if (slackChannel == null) - { - _logger.LogError($"bridge configured but cannot find target slackChannel: {bridge.Slack}"); - continue; - } + private async Task ProcessRequestAsync(LineWebhookRequest request, CancellationToken cancellationToken) + { + using var bodyStream = new MemoryStream(request.Body, writable: false); + using var document = await JsonDocument.ParseAsync(bodyStream, cancellationToken: cancellationToken); + var data = document.RootElement; + foreach (var e in data.GetProperty("events").EnumerateArray()) + { + switch (e.GetProperty("type").GetString()) + { + case "message": + { + LineChannel lineChannel = GetLineChannel(e); + if (lineChannel == null) + { + _logger.LogInformation("Message received from an unknown LINE channel."); + continue; + } - await SendToSlack(slackChannel.WebhookUrl, slackChannel.ChannelId, pictureUrl, userName, text, imageUrl); - } + var bridges = GetBridges(lineChannel); + if (!bridges.Any()) + { + continue; + } + if (e.TryGetProperty("replyToken", out var replyTokenElement)) + { + var replyToken = replyTokenElement.GetString(); + if (!string.IsNullOrEmpty(replyToken)) + { + _lastReplyTokens.AddOrUpdate(lineChannel.Id, replyToken, (key, value) => replyToken); } - break; + } + var (userName, pictureUrl) = await GetLineProfileAsync(e, cancellationToken); - default: - { - var sourceId = GetLineEventSourceId(e); - if (sourceId == null) + var message = e.GetProperty("message"); + var type = message.GetProperty("type").GetString(); + var text = ""; + string imageUrl = null; + var id = ""; + if (message.TryGetProperty("id", out var idElement)) + { + id = idElement.GetString(); + } + + switch (type) + { + case "text": + text = message.GetProperty("text").GetString(); + break; + case "sticker": + var stickerId = message.GetProperty("stickerId").GetString(); + imageUrl = $"https://stickershop.line-scdn.net/stickershop/v1/sticker/{stickerId}/android/sticker.png"; + break; + case "image": + imageUrl = $"https://{request.Host}/proxy/line/{Crypt.GetHMACHex(id, _lineChannelSecret)}/{id}"; + break; + default: + text = $"<{type}>"; + if (!string.IsNullOrEmpty(id)) { - var type = e.GetProperty("type").GetString(); - _logger.LogInformation($"{type} event from sourceId: {e.GetProperty("source").GetProperty("type").GetString()}"); - continue; + text += $"\nhttps://{request.Host}/proxy/line/{Crypt.GetHMACHex(id, _lineChannelSecret)}/{id}"; } + break; + } + + foreach (var bridge in bridges) + { + var slackChannel = _slackChannels.CurrentValue.Channels.FirstOrDefault(x => x.Name == bridge.Slack); + if (slackChannel == null) + { + _logger.LogError("Bridge configuration refers to an unknown Slack channel."); + continue; } - break; + + await SendToSlack(slackChannel.WebhookUrl, slackChannel.ChannelId, pictureUrl, userName, text, imageUrl, cancellationToken); + } } - } - } + break; - await Task.Delay(1000, stoppingToken); + default: + { + var sourceId = GetLineEventSourceId(e); + if (sourceId == null) + { + var type = e.GetProperty("type").GetString(); + _logger.LogInformation("Ignored LINE {EventType} event from an unknown source type.", type); + continue; + } + } + break; + } } - - _logger.LogDebug($"LineMessageProcessing background task is stopped."); } public string GetReplyToken(string lineChannelID) @@ -171,7 +198,7 @@ public string GetReplyToken(string lineChannelID) return null; } - private async Task SendToSlack(string webhookUrl, string channelId, string pictureUrl, string userName, string text, string imageUrl) + private async Task SendToSlack(string webhookUrl, string channelId, string pictureUrl, string userName, string text, string imageUrl, CancellationToken cancellationToken) { var client = _clientFactory.CreateClient(); @@ -198,8 +225,15 @@ private async Task SendToSlack(string webhookUrl, string channelId, string pictu } }; } - var result = await client.PostAsync(webhookUrl, new StringContent(JsonSerializer.Serialize(new Dictionary(message)), Encoding.UTF8, "application/json")); - _logger.LogInformation($"Post to Slack: {result.StatusCode} {await result.Content.ReadAsStringAsync()}"); + using var content = new StringContent(JsonSerializer.Serialize(new Dictionary(message)), Encoding.UTF8, "application/json"); + using var result = await client.PostAsync(webhookUrl, content, cancellationToken); + if (!result.IsSuccessStatusCode) + { + _logger.LogError("Slack webhook returned non-success status {StatusCode}.", result.StatusCode); + throw new HttpRequestException("Slack webhook returned a non-success status."); + } + + _logger.LogInformation("Post to Slack returned status {StatusCode}.", result.StatusCode); } private LineChannel GetLineChannel(JsonElement e) @@ -231,54 +265,66 @@ private string GetLineEventSourceId(JsonElement e) } } - private async Task<(string userName, string pictureUrl)> GetLineProfileAsync(JsonElement e) + private async Task<(string userName, string pictureUrl)> GetLineProfileAsync(JsonElement e, CancellationToken cancellationToken) { var source = e.GetProperty("source"); var type = source.GetProperty("type").GetString(); var client = _clientFactory.CreateClient("Line"); var resultProfile = (userName: "", pictureUrl: ""); - var userId = source.GetProperty("userId"); - HttpResponseMessage result = null; + var userId = source.GetProperty("userId").GetString(); + string profilePath = null; + switch (type) + { + case "user": + profilePath = $"profile/{userId}"; + break; + case "group": + var groupId = source.GetProperty("groupId").GetString(); + profilePath = $"group/{groupId}/member/{userId}"; + break; + case "room": + var roomId = source.GetProperty("roomId").GetString(); + profilePath = $"room/{roomId}/member/{userId}"; + break; + default: + _logger.LogError("Unknown LINE profile source type."); + throw new InvalidOperationException("Unknown LINE profile source type."); + } try { - switch (type) + using var result = await client.GetAsync(profilePath, cancellationToken); + if (!result.IsSuccessStatusCode) { - case "user": - result = await client.GetAsync($"profile/{userId}"); - break; - case "group": - var groupId = source.GetProperty("groupId"); - result = await client.GetAsync($"group/{groupId}/member/{userId}"); - break; - case "room": - var roomId = source.GetProperty("roomId"); - result = await client.GetAsync($"room/{roomId}/member/{userId}"); - break; - default: - _logger.LogError($"unknown source type: {type}"); - break; + _logger.LogWarning("LINE profile request returned non-success status {StatusCode}; using fallback profile.", result.StatusCode); + return ($"Unknown ({userId})", ""); } - var profile = await JsonSerializer.DeserializeAsync(await result.Content.ReadAsStreamAsync()); - _logger.LogInformation($"get profile: {profile}"); + await using var profileStream = await result.Content.ReadAsStreamAsync(cancellationToken); + var profile = await JsonSerializer.DeserializeAsync(profileStream, cancellationToken: cancellationToken); resultProfile.userName = profile.GetProperty("displayName").GetString(); if (profile.TryGetProperty("pictureUrl", out var picture)) { resultProfile.pictureUrl = picture.GetString(); } + + if (string.IsNullOrEmpty(resultProfile.userName)) + { + resultProfile.userName = $"Unknown ({userId})"; + } + + return resultProfile; } - catch (Exception ex) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - _logger.LogError(ex, "get profile data failed"); + throw; } - - if (string.IsNullOrEmpty(resultProfile.userName)) + catch (Exception ex) { - resultProfile.userName = $"Unknown ({userId})"; + _logger.LogWarning("Could not retrieve LINE profile; using fallback profile ({ExceptionType}).", ex.GetType().Name); + return ($"Unknown ({userId})", ""); } - return resultProfile; } } } diff --git a/SlackLineBridge/Services/LineWebhookQueue.cs b/SlackLineBridge/Services/LineWebhookQueue.cs new file mode 100644 index 0000000..7ed265f --- /dev/null +++ b/SlackLineBridge/Services/LineWebhookQueue.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; + +namespace SlackLineBridge.Services +{ + public sealed record LineWebhookRequest(byte[] Body, string Host); + + public sealed class LineWebhookQueue + { + public const int DefaultCapacity = 256; + + private readonly Channel _channel; + + public LineWebhookQueue(int capacity = DefaultCapacity) + { + if (capacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + + _channel = Channel.CreateBounded(new BoundedChannelOptions(capacity) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + }); + } + + public bool TryEnqueue(LineWebhookRequest request) + { + return _channel.Writer.TryWrite(request); + } + + public IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken) + { + return _channel.Reader.ReadAllAsync(cancellationToken); + } + + public void Complete() + { + _channel.Writer.TryComplete(); + } + } +} diff --git a/SlackLineBridge/Startup.cs b/SlackLineBridge/Startup.cs index e7051c9..0dc84bb 100644 --- a/SlackLineBridge/Startup.cs +++ b/SlackLineBridge/Startup.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net.Http.Headers; @@ -80,7 +79,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); } services.AddSingleton(); - services.AddSingleton>(); //lineRequestQueue + services.AddSingleton(); services.AddSingleton(new LineChannelSecret(Configuration["lineChannelSecret"])); services.AddSingleton(new SlackSigningSecret(Configuration["slackSigningSecret"])); var jsonOptions = new JsonSerializerOptions(); @@ -88,7 +87,6 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(jsonOptions); services.AddSingleton(); services.AddHostedService(sp=>sp.GetRequiredService()); - services.AddHostedService(); services.AddControllers(); } diff --git a/SlackLineBridge/Utils/Crypt.cs b/SlackLineBridge/Utils/Crypt.cs index 075e623..bbace71 100644 --- a/SlackLineBridge/Utils/Crypt.cs +++ b/SlackLineBridge/Utils/Crypt.cs @@ -12,10 +12,16 @@ public class Crypt public static byte[] CalcHMAC(string text, string key) { var encoding = new UTF8Encoding(); + return CalcHMAC(encoding.GetBytes(text), encoding.GetBytes(key)); + } - var textBytes = encoding.GetBytes(text); - var keyBytes = encoding.GetBytes(key); + public static byte[] CalcHMAC(byte[] textBytes, string key) + { + return CalcHMAC(textBytes, new UTF8Encoding().GetBytes(key)); + } + private static byte[] CalcHMAC(byte[] textBytes, byte[] keyBytes) + { using var hash = new HMACSHA256(keyBytes); return hash.ComputeHash(textBytes); } From d73e18cfc0018a5c999b51c3e62ac5e9c9187542 Mon Sep 17 00:00:00 2001 From: YDKK Date: Tue, 20 Oct 2026 22:12:15 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=E3=82=BB=E3=82=AD=E3=83=A5=E3=83=AA?= =?UTF-8?q?=E3=83=86=E3=82=A3=E4=BF=AE=E6=AD=A3=E3=81=AA=E3=81=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 署名を定数時間で比較し、Webhook本文とメディアキャッシュのサイズを制限する。 秘密情報のログ出力と意図しない認証情報の転送を防ぐ。 脆弱性のあるNuGet依存関係を更新する。 --- README.md | 9 +- .../ConfigurationSecurityTests.cs | 176 +++++++ .../LineWebhookQueueTests.cs | 13 + SlackLineBridge.Tests/MediaCacheLimitTests.cs | 340 ++++++++++++++ .../MediaProxyServiceTests.cs | 12 + .../OutboundSecurityTests.cs | 96 ++++ .../S3MediaCacheStoreTests.cs | 2 + SlackLineBridge.Tests/TokenSecurityTests.cs | 127 +++++ SlackLineBridge.Tests/WebhookSecurityTests.cs | 441 ++++++++++++++++++ .../Controllers/ProxyController.cs | 10 +- .../Controllers/WebhookController.cs | 279 +++++++---- .../Configurations/LineChannelSecret.cs | 13 +- .../Configurations/MediaCacheOptions.cs | 7 + .../Configurations/SlackSigningSecret.cs | 13 +- SlackLineBridge/Program.cs | 4 +- SlackLineBridge/Properties/AssemblyInfo.cs | 3 + SlackLineBridge/SentryPrivacyFilter.cs | 23 + SlackLineBridge/Services/MediaProxyService.cs | 43 +- SlackLineBridge/Services/SlackMediaHandler.cs | 108 +++++ SlackLineBridge/SlackLineBridge.csproj | 2 + SlackLineBridge/Startup.cs | 25 +- SlackLineBridge/Utils/Crypt.cs | 30 +- SlackLineBridge/config.json.example | 1 + 23 files changed, 1670 insertions(+), 107 deletions(-) create mode 100644 SlackLineBridge.Tests/ConfigurationSecurityTests.cs create mode 100644 SlackLineBridge.Tests/MediaCacheLimitTests.cs create mode 100644 SlackLineBridge.Tests/OutboundSecurityTests.cs create mode 100644 SlackLineBridge.Tests/TokenSecurityTests.cs create mode 100644 SlackLineBridge.Tests/WebhookSecurityTests.cs create mode 100644 SlackLineBridge/Properties/AssemblyInfo.cs create mode 100644 SlackLineBridge/SentryPrivacyFilter.cs create mode 100644 SlackLineBridge/Services/SlackMediaHandler.cs diff --git a/README.md b/README.md index 69b2b58..fd3b8b3 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ LINE/Slackの署名を検証した後、取得済みメディアをprivateなS3 { "MediaCache": { "Enabled": true, + "MaxObjectBytes": 268435456, // 最大256 MiB "Endpoint": "https://s3.example.com", // AWS S3では空欄 "Region": "us-east-1", "Bucket": "private-media-cache", @@ -99,11 +100,13 @@ LINE/Slackの署名を検証した後、取得済みメディアをprivateなS3 } ``` -有効時は `Region` と `Bucket` が必須です。明示資格情報を使う場合はAccessKey/SecretKeyを両方設定します。資格情報には対象prefix内の `s3:GetObject` と `s3:PutObject`、およびバケットの `s3:ListBucket` を許可してください。AWSではListBucket権限がないと不存在キーが403になる場合があり、このキャッシュは403をmissとして扱いません。バケットは公開せず、AWS側のBlock Public Accessも有効にしてください。保存キーにはprovider名とメディアID/URLのSHA-256だけを使います。 +有効時は `Region` と `Bucket` が必須です。明示資格情報を使う場合はAccessKey/SecretKeyを両方設定します。資格情報には対象prefix内の `s3:GetObject` と `s3:PutObject`、およびバケットの `s3:ListBucket` を許可してください。AWSではListBucket権限がないと不存在キーが403になる場合があり、このキャッシュは403をミスとして扱いません。バケットは公開せず、AWS側のBlock Public Accessも有効にしてください。保存キーにはprovider名とメディアID/URLのSHA-256だけを使います。 -画像・動画・その他のファイルをMIME typeに関係なく保存します。オブジェクトの自動削除は行わないため、必要ならバケットのLifecycleルールで保持期間を設定してください。miss時は上流の全データを一時ファイルへ保存してからS3へ登録するため、OSの一時領域には同時取得中のファイル合計を置ける空き容量が必要です。メモリには固定サイズのbufferで転送します。 +キャッシュミス時に一時保存するオブジェクトの上限は `MaxObjectBytes`(既定256 MiB)です。超過した取得は失敗し、一時ファイルを削除します。 -S3の読み書きに失敗した場合はキャッシュmissとして扱わずHTTP 503を返します。上流HTTPエラーのstatus codeは保持し、エラー本文はキャッシュしません。 +画像・動画・その他のファイルをMIME typeに関係なく保存します。オブジェクトの自動削除は行わないため、必要ならバケットのLifecycleルールで保持期間を設定してください。ミス時は上流の全データを一時ファイルへ保存してからS3へ登録するため、OSの一時領域には同時取得中のファイル合計を置ける空き容量が必要です。メモリには固定サイズのbufferで転送します。 + +S3の読み書きに失敗した場合はキャッシュミスとして扱わずHTTP 503を返します。上流HTTPエラーのstatus codeは保持し、エラー本文はキャッシュしません。 ## スクリーンショット diff --git a/SlackLineBridge.Tests/ConfigurationSecurityTests.cs b/SlackLineBridge.Tests/ConfigurationSecurityTests.cs new file mode 100644 index 0000000..4a3ffba --- /dev/null +++ b/SlackLineBridge.Tests/ConfigurationSecurityTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Sentry; +using SlackLineBridge.Models.Configurations; +using Xunit; + +namespace SlackLineBridge.Tests; + +public sealed class ConfigurationSecurityTests +{ + // Sentry SDKのevent JSONにURL、query、header、cookieを残さない。 + [Fact] + public void SentryPrivacyFilterRemovesRequestMetadata() + { + const string urlMarker = "private-request-url-marker"; + const string queryMarker = "private-query-marker"; + const string headerMarker = "private-header-marker"; + const string cookieMarker = "private-cookie-marker"; + var request = new SentryRequest + { + Url = $"https://bridge.example.com/proxy/{urlMarker}?value={queryMarker}", + QueryString = queryMarker, + Cookies = cookieMarker + }; + request.Headers["Authorization"] = headerMarker; + var sentryEvent = new SentryEvent { Request = request }; + + var filtered = SentryPrivacyFilter.RemoveRequestMetadata(sentryEvent); + using var stream = new MemoryStream(); + using var writer = new System.Text.Json.Utf8JsonWriter(stream); + filtered.WriteTo(writer, null); + writer.Flush(); + var serialized = Encoding.UTF8.GetString(stream.ToArray()); + + Assert.DoesNotContain(urlMarker, serialized, StringComparison.Ordinal); + Assert.DoesNotContain(queryMarker, serialized, StringComparison.Ordinal); + Assert.DoesNotContain(headerMarker, serialized, StringComparison.Ordinal); + Assert.DoesNotContain(cookieMarker, serialized, StringComparison.Ordinal); + Assert.DoesNotContain("\"request\"", serialized, StringComparison.Ordinal); + } + + // SentryのHTTP breadcrumbを落とし、通常のbreadcrumbは維持する。 + [Fact] + public void SentryPrivacyFilterDropsHttpBreadcrumbs() + { + var httpBreadcrumb = new Breadcrumb( + "GET private URL", + "http", + new Dictionary { ["url"] = "https://bridge.example.com/private-token" }, + "http", + BreadcrumbLevel.Info); + var appBreadcrumb = new Breadcrumb("application event", "default", null, "app", BreadcrumbLevel.Info); + + Assert.Null(SentryPrivacyFilter.DropHttpBreadcrumb(httpBreadcrumb)); + Assert.Same(appBreadcrumb, SentryPrivacyFilter.DropHttpBreadcrumb(appBreadcrumb)); + } + + // DI経由の既定、Line、Slack、SlackMedia clientのログからURL tokenを除く。 + [Fact] + public async Task HttpClientFactoryDoesNotLogSecretUrls() + { + const string secretMarker = "private-url-token-marker"; + var settings = CreateValidSettings(); + settings["Logging:LogLevel:Default"] = "Trace"; + settings["Logging:LogLevel:Microsoft"] = "Trace"; + var loggerProvider = new CapturingLoggerProvider(); + var services = new ServiceCollection(); + new Startup(new ConfigurationBuilder().AddInMemoryCollection(settings).Build()).ConfigureServices(services); + services.AddLogging(logging => logging.AddProvider(loggerProvider)); + + foreach (var name in new[] { "", "Line", "Slack", "SlackMedia" }) + { + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => new OkHandler()); + } + + using var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); + var requests = new[] + { + (Client: "", Url: $"https://default.example/{secretMarker}"), + (Client: "Line", Url: $"https://api.line.me/v2/bot/{secretMarker}"), + (Client: "Slack", Url: $"https://slack.example/{secretMarker}"), + (Client: "SlackMedia", Url: $"https://files.slack.com/{secretMarker}") + }; + + foreach (var request in requests) + { + using var client = factory.CreateClient(request.Client); + using var response = await client.GetAsync(request.Url); + Assert.Equal(200, (int)response.StatusCode); + } + + Assert.DoesNotContain(loggerProvider.Entries, entry => entry.Contains(secretMarker, StringComparison.Ordinal)); + } + + // LINEとSlackのsecret型が空白値を拒否することを確認する。 + [Fact] + public void SecretTypesRejectBlankValues() + { + Assert.Throws(() => new LineChannelSecret(" ")); + Assert.Throws(() => new SlackSigningSecret("\t")); + } + + // 必須secretやaccess tokenが欠けた設定ではサービス登録を失敗させる。 + [Theory] + [InlineData("lineChannelSecret")] + [InlineData("slackSigningSecret")] + [InlineData("lineAccessToken")] + [InlineData("slackAccessToken")] + public void StartupRejectsBlankSecretsAndTokens(string key) + { + var settings = CreateValidSettings(); + settings[key] = " "; + var startup = new Startup(new ConfigurationBuilder().AddInMemoryCollection(settings).Build()); + + var exception = Assert.Throws(() => startup.ConfigureServices(new ServiceCollection())); + + Assert.DoesNotContain("test-", exception.Message, StringComparison.Ordinal); + } + + private static Dictionary CreateValidSettings() + { + return new Dictionary + { + ["lineChannelSecret"] = "test-line-secret", + ["slackSigningSecret"] = "test-slack-secret", + ["lineAccessToken"] = "test-line-token", + ["slackAccessToken"] = "test-slack-token", + ["Logging:UseCloudWatchLogs"] = "false" + }; + } + + private sealed class OkHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)); + } + } + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + public ConcurrentQueue Entries { get; } = new(); + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries, categoryName); + + public void Dispose() + { + } + + private sealed class CapturingLogger(ConcurrentQueue entries, string categoryName) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var formatted = formatter(state, exception); + var details = state is IEnumerable> values + ? string.Join(" ", values.Select(value => $"{value.Key}={value.Value}")) + : state is null ? null : state.ToString(); + entries.Enqueue($"{categoryName} {formatted} {details} {exception}"); + } + } + } +} diff --git a/SlackLineBridge.Tests/LineWebhookQueueTests.cs b/SlackLineBridge.Tests/LineWebhookQueueTests.cs index 3797c95..c328cb1 100644 --- a/SlackLineBridge.Tests/LineWebhookQueueTests.cs +++ b/SlackLineBridge.Tests/LineWebhookQueueTests.cs @@ -29,6 +29,7 @@ public sealed class LineWebhookQueueTests { private const string LineSecret = "test-line-channel-secret"; + // 署名ヘッダー欠落または不正時に要求をキューへ登録しないことを確認する。 [Fact] public async Task MissingAndInvalidSignaturesDoNotEnqueueRequests() { @@ -46,6 +47,7 @@ public async Task MissingAndInvalidSignaturesDoNotEnqueueRequests() Assert.Equal(marker, await ReadNextAsync(queue)); } + // 正しい署名の要求をキューへ登録してからHTTP 200を返すことを確認する。 [Fact] public async Task ValidSignatureIsEnqueuedBeforeHttpOkIsReturned() { @@ -61,6 +63,7 @@ public async Task ValidSignatureIsEnqueuedBeforeHttpOkIsReturned() Assert.Equal("bridge.example", request.Host); } + // LINE署名を本文の受信時バイト列そのもので検証することを確認する。 [Fact] public async Task SignatureValidationUsesExactRawRequestBytes() { @@ -76,6 +79,7 @@ public async Task SignatureValidationUsesExactRawRequestBytes() Assert.Equal(body, (await ReadNextAsync(queue)).Body); } + // 本文読み取り中に要求が中断された場合はキューへ登録しないことを確認する。 [Fact] public async Task AbortedRequestBodyReadDoesNotEnqueue() { @@ -97,6 +101,7 @@ public async Task AbortedRequestBodyReadDoesNotEnqueue() Assert.Equal(marker, await ReadNextAsync(queue)); } + // LINE webhookの応答が外部プロフィール取得の完了を待たないことを確認する。 [Fact] public async Task HttpAcknowledgementDoesNotWaitForExternalProfileRequest() { @@ -129,6 +134,7 @@ public async Task HttpAcknowledgementDoesNotWaitForExternalProfileRequest() await StopAsync(service); } + // Kestrel経由で早期応答し、不正署名と満杯キューを拒否することを確認する。 [Fact] public async Task KestrelPipelineAcknowledgesBeforeProcessingAndRejectsInvalidOrFullQueue() { @@ -215,6 +221,7 @@ public async Task KestrelPipelineAcknowledgesBeforeProcessingAndRejectsInvalidOr } } + // キュー内の連続した要求を待機遅延なく処理することを確認する。 [Fact] public async Task ConsumerProcessesConsecutiveRequestsWithoutPollingDelay() { @@ -246,6 +253,7 @@ public async Task ConsumerProcessesConsecutiveRequestsWithoutPollingDelay() Assert.Equal(2, slackPosts); } + // 不正形式や処理失敗の後も次の要求を処理し、ログに秘密を含めないことを確認する。 [Fact] public async Task MalformedAndFailedRequestsDoNotStopTheNextRequestAndLogsOmitSecrets() { @@ -291,6 +299,7 @@ public async Task MalformedAndFailedRequestsDoNotStopTheNextRequestAndLogsOmitSe Assert.DoesNotContain("slack-webhook-token", logs, StringComparison.Ordinal); } + // LINEプロフィール取得失敗時に代替プロフィールを使って転送を続けることを確認する。 [Fact] public async Task NonSuccessProfileResponseUsesFallbackAndStillForwardsMessage() { @@ -319,6 +328,7 @@ public async Task NonSuccessProfileResponseUsesFallbackAndStillForwardsMessage() Assert.Contains(provider.Messages, message => message.Contains("BadGateway", StringComparison.Ordinal)); } + // Slack送信失敗をログに記録し、後続要求の処理を続けることを確認する。 [Fact] public async Task NonSuccessSlackResponseIsLoggedAsFailureAndNextRequestContinues() { @@ -355,6 +365,7 @@ public async Task NonSuccessSlackResponseIsLoggedAsFailureAndNextRequestContinue Assert.Single(provider.Messages, message => message.Contains("Post to Slack returned status", StringComparison.Ordinal)); } + // キュー満杯時に待たずにHTTP 503を返すことを確認する。 [Fact] public async Task FullQueueReturnsServiceUnavailableWithoutWaiting() { @@ -371,6 +382,7 @@ public async Task FullQueueReturnsServiceUnavailableWithoutWaiting() Assert.Equal(queued, await ReadNextAsync(queue)); } + // 正常停止時に受付済み要求を処理し、新しい要求を受け付けないことを確認する。 [Fact] public async Task GracefulStopDrainsAcceptedRequestsAndClosesQueue() { @@ -396,6 +408,7 @@ public async Task GracefulStopDrainsAcceptedRequestsAndClosesQueue() Assert.False(queue.TryEnqueue(QueueRequest("after-stop", "bridge.example"))); } + // 停止期限に達したとき実行中の外部要求をキャンセルすることを確認する。 [Fact] public async Task ShutdownDeadlineCancelsActiveExternalRequest() { diff --git a/SlackLineBridge.Tests/MediaCacheLimitTests.cs b/SlackLineBridge.Tests/MediaCacheLimitTests.cs new file mode 100644 index 0000000..bbddce5 --- /dev/null +++ b/SlackLineBridge.Tests/MediaCacheLimitTests.cs @@ -0,0 +1,340 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using SlackLineBridge.Models.Configurations; +using SlackLineBridge.Services; +using Xunit; + +namespace SlackLineBridge.Tests; + +[CollectionDefinition("Media cache size limit tests", DisableParallelization = true)] +public sealed class MediaCacheLimitTestCollection +{ +} + +[Collection("Media cache size limit tests")] +public sealed class MediaCacheLimitTests +{ + private const long MaxObjectBytes = 4; + private const string UpstreamUrl = "https://media.example.test/object"; + + // 上限の既定値が256MiBで、キャッシュ有効時は正数が必須であることを確認する。 + [Fact] + public void MaxObjectBytesDefaultsTo256MiBAndMustBePositiveWhenCacheEnabled() + { + Assert.Equal(256L * 1024 * 1024, new MediaCacheOptions().MaxObjectBytes); + + foreach (var invalidLimit in new[] { 0L, -1L }) + { + var options = new MediaCacheOptions + { + Enabled = true, + Region = "test-region", + Bucket = "test-bucket", + MaxObjectBytes = invalidLimit + }; + + Assert.Contains(options.GetValidationErrors(), error => error.Contains("MediaCache:MaxObjectBytes", StringComparison.Ordinal)); + } + } + + // 宣言された本文サイズが上限を超える場合は読み取りや一時ファイル作成をせず502にすることを確認する。 + [Fact] + public async Task DeclaredOversizeResponseIsRejectedBeforeReadingOrCreatingTempFile() + { + var store = new RecordingMediaCacheStore(); + var upstream = new ReadTrackingStream(new byte[] { 1, 2, 3, 4, 5 }); + var handler = new StaticHandler(() => CreateResponse(upstream, MaxObjectBytes + 1)); + using var client = new HttpClient(handler); + var service = CreateService(store); + var tempFilesBefore = GetTempFiles(); + + var result = await service.GetAsync( + client, + UpstreamUrl, + MediaCacheSource.Slack, + UpstreamUrl, + null, + false, + CancellationToken.None); + + try + { + Assert.Equal((int)HttpStatusCode.BadGateway, result.StatusCode); + Assert.Null(result.Content); + Assert.Equal(0, upstream.BytesRead); + Assert.Equal(0, store.PutCount); + AssertTempFilesUnchanged(tempFilesBefore); + } + finally + { + if (result.Content != null) + { + await result.Content.DisposeAsync(); + } + } + } + + // Content-Lengthがない場合や実サイズより小さい場合も上限+1 byteで読み取りを止めて502にすることを確認する。 + [Theory] + [InlineData(null)] + [InlineData(3L)] + public async Task OversizedResponseWithMissingOrUnderreportedLengthIsRejectedAndCleaned(long? declaredLength) + { + var store = new RecordingMediaCacheStore(); + var upstream = new ReadTrackingStream(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }); + var handler = new StaticHandler(() => CreateResponse(upstream, declaredLength)); + using var client = new HttpClient(handler); + var service = CreateService(store); + var tempFilesBefore = GetTempFiles(); + + var result = await service.GetAsync( + client, + UpstreamUrl, + MediaCacheSource.Slack, + UpstreamUrl, + null, + false, + CancellationToken.None); + + try + { + Assert.Equal((int)HttpStatusCode.BadGateway, result.StatusCode); + Assert.Null(result.Content); + Assert.Equal(MaxObjectBytes + 1, upstream.BytesRead); + Assert.Equal(0, store.PutCount); + AssertTempFilesUnchanged(tempFilesBefore); + } + finally + { + if (result.Content != null) + { + await result.Content.DisposeAsync(); + } + } + } + + // 実データが上限と同じ長さなら保存して成功応答を返すことを確認する。 + [Fact] + public async Task ResponseAtExactLimitIsCachedAndReturnedSuccessfully() + { + var body = new byte[] { 1, 2, 3, 4 }; + var store = new RecordingMediaCacheStore(); + var upstream = new ReadTrackingStream(body); + var handler = new StaticHandler(() => CreateResponse(upstream, MaxObjectBytes)); + using var client = new HttpClient(handler); + var service = CreateService(store); + var tempFilesBefore = GetTempFiles(); + + var result = await service.GetAsync( + client, + UpstreamUrl, + MediaCacheSource.Slack, + UpstreamUrl, + null, + false, + CancellationToken.None); + + try + { + Assert.Equal((int)HttpStatusCode.OK, result.StatusCode); + Assert.Equal((long?)MaxObjectBytes, result.ContentLength); + Assert.Equal(body, store.StoredBody); + Assert.Equal(body, await ReadAllAsync(result.Content)); + } + finally + { + if (result.Content != null) + { + await result.Content.DisposeAsync(); + } + } + + Assert.Equal(1, store.PutCount); + AssertTempFilesUnchanged(tempFilesBefore); + } + + private static MediaProxyService CreateService(RecordingMediaCacheStore store) + { + return new MediaProxyService( + store, + Options.Create(new MediaCacheOptions + { + Enabled = true, + Region = "test-region", + Bucket = "test-bucket", + MaxObjectBytes = MaxObjectBytes + })); + } + + private static HttpResponseMessage CreateResponse(ReadTrackingStream stream, long? contentLength) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ReadTrackingContent(stream, contentLength) + }; + } + + private static async Task ReadAllAsync(Stream stream) + { + await using var copy = new MemoryStream(); + await stream.CopyToAsync(copy); + return copy.ToArray(); + } + + private static string[] GetTempFiles() + { + return Directory.GetFiles(Path.GetTempPath(), "slacklinebridge-mediacache-*.tmp"); + } + + private static void AssertTempFilesUnchanged(string[] before) + { + var after = GetTempFiles(); + Assert.True(before.OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .SequenceEqual(after.OrderBy(path => path, StringComparer.OrdinalIgnoreCase))); + } + + private sealed class RecordingMediaCacheStore : IMediaCacheStore + { + public int PutCount { get; private set; } + + public byte[] StoredBody { get; private set; } = Array.Empty(); + + public Task GetAsync(string key, string singleByteRange, CancellationToken cancellationToken) + { + return Task.FromResult(null!); + } + + public async Task PutAsync(string key, Stream content, string contentType, long contentLength, CancellationToken cancellationToken) + { + PutCount++; + await using var copy = new MemoryStream(); + await content.CopyToAsync(copy, cancellationToken); + StoredBody = copy.ToArray(); + } + } + + private sealed class StaticHandler(Func responseFactory) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(responseFactory()); + } + } + + private sealed class ReadTrackingContent : HttpContent + { + private readonly ReadTrackingStream _stream; + private readonly long? _contentLength; + + public ReadTrackingContent(ReadTrackingStream stream, long? contentLength) + { + _stream = stream; + _contentLength = contentLength; + Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + Headers.ContentLength = contentLength; + } + + protected override Task SerializeToStreamAsync(Stream stream, System.Net.TransportContext? context) + { + return _stream.CopyToAsync(stream); + } + + protected override bool TryComputeLength(out long length) + { + if (_contentLength is long contentLength) + { + length = contentLength; + return true; + } + + length = 0; + return false; + } + + protected override Task CreateContentReadStreamAsync() + { + return Task.FromResult(_stream); + } + + protected override Task CreateContentReadStreamAsync(CancellationToken cancellationToken) + { + return Task.FromResult(_stream); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _stream.Dispose(); + } + + base.Dispose(disposing); + } + } + + private sealed class ReadTrackingStream(byte[] body) : Stream + { + private int _position; + + public long BytesRead { get; private set; } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + var bytesRead = Math.Min(count, body.Length - _position); + Array.Copy(body, _position, buffer, offset, bytesRead); + _position += bytesRead; + BytesRead += bytesRead; + return bytesRead; + } + + public override int Read(Span buffer) + { + var bytesRead = Math.Min(buffer.Length, body.Length - _position); + body.AsSpan(_position, bytesRead).CopyTo(buffer); + _position += bytesRead; + BytesRead += bytesRead; + return bytesRead; + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var bytesRead = Math.Min(buffer.Length, body.Length - _position); + body.AsMemory(_position, bytesRead).CopyTo(buffer); + _position += bytesRead; + BytesRead += bytesRead; + return ValueTask.FromResult(bytesRead); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/SlackLineBridge.Tests/MediaProxyServiceTests.cs b/SlackLineBridge.Tests/MediaProxyServiceTests.cs index 57a7cc6..1d15cde 100644 --- a/SlackLineBridge.Tests/MediaProxyServiceTests.cs +++ b/SlackLineBridge.Tests/MediaProxyServiceTests.cs @@ -26,6 +26,7 @@ public sealed class MediaProxyServiceTests { private const string UpstreamUrl = "https://media.example.test/object"; + // 初回取得でMIME typeを問わず保存し、2回目はキャッシュから返すことを確認する。 [Fact] public async Task FirstRequestStoresEveryMimeTypeAndSecondRequestUsesCache() { @@ -54,6 +55,7 @@ public async Task FirstRequestStoresEveryMimeTypeAndSecondRequestUsesCache() Assert.Single(store.Keys.Distinct()); } + // 無効なLINE署名ではキャッシュや上流へアクセスしないことを確認する。 [Fact] public async Task InvalidLineSignatureDoesNotAccessCacheOrUpstream() { @@ -75,6 +77,7 @@ public async Task InvalidLineSignatureDoesNotAccessCacheOrUpstream() Assert.Equal(0, handler.RequestCount); } + // キャッシュキーがproviderごとに分かれ、元の識別情報を含まないことを確認する。 [Fact] public void CacheKeysSeparateProvidersAndDoNotContainIdentity() { @@ -91,6 +94,7 @@ public void CacheKeysSeparateProvidersAndDoNotContainIdentity() Assert.EndsWith(lineKey.Split('/').Last(), slackKey); } + // キャッシュ無効時は設定を許容し、有効時は不正な設定を拒否することを確認する。 [Fact] public void CacheConfigurationIsValidatedOnlyWhenEnabled() { @@ -102,6 +106,7 @@ public void CacheConfigurationIsValidatedOnlyWhenEnabled() Assert.Contains(errors, error => error.Contains("MediaCache:Bucket", StringComparison.Ordinal)); } + // キャッシュmiss/hitのRange応答と通常hitの本文・ヘッダーが正しいことを確認する。 [Fact] public async Task MvcReturnsCorrectRangeHeadersForMissAndHitAndFullContentOnUnrangedHit() { @@ -145,6 +150,7 @@ public async Task MvcReturnsCorrectRangeHeadersForMissAndHitAndFullContentOnUnra mvcServices.Dispose(); } + // キャッシュhitで単一byte rangeの内容と応答が正しいことを確認する。 [Fact] public async Task CacheHitUsesSingleByteRangeFromS3Object() { @@ -167,6 +173,7 @@ public async Task CacheHitUsesSingleByteRangeFromS3Object() Assert.Equal(1, handler.RequestCount); } + // S3保存失敗時に一時ファイルを削除し、成功応答を返さないことを確認する。 [Fact] public async Task FailedPutDeletesTemporaryFileAndDoesNotReturnSuccess() { @@ -183,6 +190,7 @@ await Assert.ThrowsAsync(() => service.GetAsync( Assert.Equal(0, store.StoredObjectCount); } + // 上流読み取りのキャンセル時に一時ファイルを削除することを確認する。 [Fact] public async Task AbortedUpstreamReadDeletesTemporaryFile() { @@ -206,6 +214,7 @@ public async Task AbortedUpstreamReadDeletesTemporaryFile() Assert.Equal(0, store.StoredObjectCount); } + // 上流エラーのstatus codeを保持し、エラー本文をキャッシュしないことを確認する。 [Fact] public async Task UpstreamErrorStatusIsKeptAndErrorBodyIsNotCached() { @@ -224,6 +233,7 @@ public async Task UpstreamErrorStatusIsKeptAndErrorBodyIsNotCached() Assert.Equal(0, store.PutCount); } + // キャッシュ無効時にストレージへアクセスせず上流から取得することを確認する。 [Fact] public async Task DisabledCacheDoesNotAccessStorage() { @@ -242,6 +252,7 @@ public async Task DisabledCacheDoesNotAccessStorage() Assert.Equal(1, handler.RequestCount); } + // ストレージ障害時に503を返し、上流へアクセスしないことを確認する。 [Fact] public async Task StorageFailureReturns503WithoutCallingUpstream() { @@ -258,6 +269,7 @@ public async Task StorageFailureReturns503WithoutCallingUpstream() mvcServices.Dispose(); } + // 同じオブジェクトへの並行missで上流取得と保存を一度だけ行うことを確認する。 [Fact] public async Task ConcurrentMissesForSameObjectOnlyFetchAndStoreOnce() { diff --git a/SlackLineBridge.Tests/OutboundSecurityTests.cs b/SlackLineBridge.Tests/OutboundSecurityTests.cs new file mode 100644 index 0000000..1df40d1 --- /dev/null +++ b/SlackLineBridge.Tests/OutboundSecurityTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using SlackLineBridge.Services; +using Xunit; + +namespace SlackLineBridge.Tests; + +public sealed class OutboundSecurityTests +{ + // HTTPや別ホストにはBearer tokenを送らないことを確認する。 + [Fact] + public async Task BearerTokenIsSentOnlyToSlackOverHttps() + { + var innerHandler = new RecordingHandler( + _ => new HttpResponseMessage(HttpStatusCode.Found) + { + Headers = { Location = new Uri("https://future-cdn.example.test:9443/files/a") } + }, + _ => new HttpResponseMessage(HttpStatusCode.Found) + { + Headers = { Location = new Uri("http://files.slack.com/files/b") } + }, + _ => new HttpResponseMessage(HttpStatusCode.OK)); + using var client = new HttpClient(new SlackMediaHandler("test-slack-token", innerHandler)); + + using var response = await client.GetAsync("https://files.slack.com/files/a"); + using var plainHttpResponse = await client.GetAsync("http://files.slack.com/files/c"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, plainHttpResponse.StatusCode); + Assert.Equal(4, innerHandler.Requests.Count); + Assert.Equal("Bearer test-slack-token", innerHandler.Requests[0].Authorization); + Assert.Null(innerHandler.Requests[1].Authorization); + Assert.Equal(new Uri("https://future-cdn.example.test:9443/files/a"), innerHandler.Requests[1].Uri); + Assert.Equal(new Uri("http://files.slack.com/files/b"), innerHandler.Requests[2].Uri); + Assert.Null(innerHandler.Requests[2].Authorization); + Assert.Equal(new Uri("http://files.slack.com/files/c"), innerHandler.Requests[3].Uri); + Assert.Null(innerHandler.Requests[3].Authorization); + } + + // 相対Locationを現在のSlack URLに対して解決して追従することを確認する。 + [Fact] + public async Task RelativeRedirectIsResolvedSafely() + { + var innerHandler = new RecordingHandler( + _ => new HttpResponseMessage(HttpStatusCode.Found) + { + Headers = { Location = new Uri("/files/b", UriKind.Relative) } + }, + _ => new HttpResponseMessage(HttpStatusCode.OK)); + using var client = new HttpClient(new SlackMediaHandler("test-slack-token", innerHandler)); + + using var response = await client.GetAsync("https://files.slack.com/files/a"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(new Uri("https://files.slack.com/files/b"), innerHandler.Requests[1].Uri); + Assert.Equal("Bearer test-slack-token", innerHandler.Requests[1].Authorization); + } + + // redirectを5回までに制限し、上限後に通信しないことを確認する。 + [Fact] + public async Task RedirectLoopStopsAtMaximumHopCount() + { + var innerHandler = new RecordingHandler(_ => new HttpResponseMessage(HttpStatusCode.Found) + { + Headers = { Location = new Uri("/next", UriKind.Relative) } + }); + using var client = new HttpClient(new SlackMediaHandler("test-slack-token", innerHandler)); + + await Assert.ThrowsAsync(() => client.GetAsync("https://files.slack.com/files/a")); + + Assert.Equal(6, innerHandler.Requests.Count); + } + + private sealed class RecordingHandler(params Func[] responses) : HttpMessageHandler + { + private int _responseIndex; + + public List Requests { get; } = new(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(new RecordedRequest( + request.RequestUri ?? throw new InvalidOperationException("Expected an absolute test request URI."), + request.Headers.Authorization?.ToString())); + var responseIndex = Math.Min(_responseIndex++, responses.Length - 1); + return Task.FromResult(responses[responseIndex](request)); + } + } + + private sealed record RecordedRequest(Uri Uri, string? Authorization); +} diff --git a/SlackLineBridge.Tests/S3MediaCacheStoreTests.cs b/SlackLineBridge.Tests/S3MediaCacheStoreTests.cs index 25da544..9b8625d 100644 --- a/SlackLineBridge.Tests/S3MediaCacheStoreTests.cs +++ b/SlackLineBridge.Tests/S3MediaCacheStoreTests.cs @@ -22,6 +22,7 @@ namespace SlackLineBridge.Tests; public sealed class S3MediaCacheStoreTests { + // S3互換エンドポイントで署名付きの保存・取得・欠損・範囲取得が動作することを確認する。 [Fact] public async Task AwsSdkV4UsesS3CompatibleWireOperationsForPutGetMissingAndRange() { @@ -80,6 +81,7 @@ public async Task AwsSdkV4UsesS3CompatibleWireOperationsForPutGetMissingAndRange Assert.False(server.SawOptionalChecksumHeaders); } + // バケット不存在をキャッシュミスではなくストレージ障害として扱うことを確認する。 [Fact] public async Task NoSuchBucketIsAStorageFailureAndNeverACacheMiss() { diff --git a/SlackLineBridge.Tests/TokenSecurityTests.cs b/SlackLineBridge.Tests/TokenSecurityTests.cs new file mode 100644 index 0000000..e1ba7f4 --- /dev/null +++ b/SlackLineBridge.Tests/TokenSecurityTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using SlackLineBridge.Controllers; +using SlackLineBridge.Models.Configurations; +using SlackLineBridge.Services; +using SlackLineBridge.Utils; +using Xunit; + +namespace SlackLineBridge.Tests; + +public sealed class TokenSecurityTests +{ + // ASCII署名は一致時のみ許可し、長さ不一致や非ASCII文字列を拒否することを確認する。 + [Fact] + public void FixedTimeAsciiComparisonRejectsMismatchesAndNonAscii() + { + Assert.True(Crypt.FixedTimeEqualsAscii("0123456789abcdef", "0123456789abcdef")); + Assert.False(Crypt.FixedTimeEqualsAscii("0123456789abcdef", "0123456789abcdeg")); + Assert.False(Crypt.FixedTimeEqualsAscii("0123456789abcdef", "short")); + Assert.False(Crypt.FixedTimeEqualsAscii("0123456789abcdef", "0123456789abcd€f")); + Assert.False(Crypt.FixedTimeEqualsAscii("expected", null!)); + } + + // 生成する互換トークンが従来どおり小文字64桁の16進数であることを確認する。 + [Fact] + public void HmacHexPreservesLowercaseHexTokenFormat() + { + var token = Crypt.GetHMACHex("message-id", "line-secret"); + + Assert.Equal(64, token.Length); + Assert.Equal(token.ToLowerInvariant(), token); + Assert.Matches("^[0-9a-f]{64}$", token); + } + + // LINEとSlackの不正トークンを拒否し、HTTPクライアントも上流サービスも使わないことを確認する。 + [Fact] + public async Task ProxyRejectsInvalidTokensBeforeCreatingClientOrAccessingMedia() + { + var clientFactory = new CountingHttpClientFactory(); + var mediaService = new CountingMediaProxyService(); + var controller = new ProxyController( + NullLogger.Instance, + clientFactory, + mediaService, + new LineChannelSecret("line-secret"), + new SlackSigningSecret("slack-secret")); + controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + + var lineResult = await controller.Line("message-id", "short-token"); + var slackResult = await controller.Slack("https%3A%2F%2Fmedia.example.test%2Ffile", new string('a', 64)); + var nonAsciiToken = new string('a', 20) + "€" + new string('a', 43); + var nonAsciiSlackResult = await controller.Slack("https%3A%2F%2Fmedia.example.test%2Ffile", nonAsciiToken); + + Assert.Equal((int)HttpStatusCode.Forbidden, Assert.IsType(lineResult).StatusCode); + Assert.Equal((int)HttpStatusCode.Forbidden, Assert.IsType(slackResult).StatusCode); + Assert.Equal((int)HttpStatusCode.Forbidden, Assert.IsType(nonAsciiSlackResult).StatusCode); + Assert.Equal(0, clientFactory.CreateCount); + Assert.Equal(0, mediaService.RequestCount); + } + + // Slackメディアには専用クライアントを使い、安全な応答ヘッダーを付けることを確認する。 + [Fact] + public async Task ProxyUsesSlackMediaClientAndSandboxResponseHeaders() + { + const string url = "https://files.slack.com/files-private/file"; + var clientFactory = new CountingHttpClientFactory(); + var mediaService = new CountingMediaProxyService(); + var controller = CreateController(clientFactory, mediaService); + + await controller.Slack(Uri.EscapeDataString(url), Crypt.GetHMACHex(url, "slack-secret")); + + Assert.Equal("SlackMedia", clientFactory.LastName); + Assert.Equal("nosniff", controller.Response.Headers["X-Content-Type-Options"].ToString()); + Assert.Equal("sandbox; default-src 'none'", controller.Response.Headers["Content-Security-Policy"].ToString()); + } + + private static ProxyController CreateController(CountingHttpClientFactory clientFactory, CountingMediaProxyService mediaService) + { + var controller = new ProxyController( + NullLogger.Instance, + clientFactory, + mediaService, + new LineChannelSecret("line-secret"), + new SlackSigningSecret("slack-secret")); + controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + return controller; + } + + private sealed class CountingHttpClientFactory : IHttpClientFactory + { + public int CreateCount { get; private set; } + public string? LastName { get; private set; } + + public HttpClient CreateClient(string name) + { + CreateCount++; + LastName = name; + return new HttpClient(); + } + } + + private sealed class CountingMediaProxyService : IMediaProxyService + { + public int RequestCount { get; private set; } + public string? Url { get; private set; } + + public Task GetAsync( + HttpClient client, + string url, + MediaCacheSource source, + string identity, + string rangeHeader, + bool hasIfRange, + CancellationToken cancellationToken) + { + RequestCount++; + Url = url; + return Task.FromResult(new MediaProxyResponse((int)HttpStatusCode.OK)); + } + } +} diff --git a/SlackLineBridge.Tests/WebhookSecurityTests.cs b/SlackLineBridge.Tests/WebhookSecurityTests.cs new file mode 100644 index 0000000..b3bb7a1 --- /dev/null +++ b/SlackLineBridge.Tests/WebhookSecurityTests.cs @@ -0,0 +1,441 @@ +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Samples; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SlackLineBridge.Controllers; +using SlackLineBridge.Models.Configurations; +using SlackLineBridge.Services; +using SlackLineBridge.Utils; +using Xunit; + +namespace SlackLineBridge.Tests; + +public sealed class WebhookSecurityTests +{ + private const string LineSecret = "test-line-secret"; + private const string SlackSecret = "test-slack-secret"; + private const int MaximumBodySize = 1024 * 1024; + + // 受信時の本文バイト列と時刻ヘッダー文字列を使ってSlack署名を検証することを確認する。 + [Fact] + public async Task SlackAcceptsValidSignatureOverRawBomBodyAndOriginalTimestampText() + { + var json = Encoding.UTF8.GetBytes("{\"type\":\"url_verification\",\"challenge\":\"challenge-marker\"}"); + var body = new byte[] { 0xEF, 0xBB, 0xBF }.Concat(json).ToArray(); + var timestamp = "0" + DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + var controller = CreateController(body); + SetSlackHeaders(controller, timestamp, SlackSignature(body, timestamp)); + + var result = await controller.Slack2(); + + Assert.Equal("challenge-marker", Assert.IsType(result).Value); + } + + // 本文改ざんと長さの異なる署名をSlackが拒否することを確認する。 + [Fact] + public async Task SlackRejectsTamperedBodyAndMalformedSignature() + { + var original = Encoding.UTF8.GetBytes("{\"type\":\"url_verification\",\"challenge\":\"original\"}"); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + var tampered = Encoding.UTF8.GetBytes("{\"type\":\"url_verification\",\"challenge\":\"tampered\"}"); + var controller = CreateController(tampered); + SetSlackHeaders(controller, timestamp, SlackSignature(original, timestamp)); + + Assert.IsType(await controller.Slack2()); + + var shortSignature = CreateController(original); + SetSlackHeaders(shortSignature, timestamp, "v0=bad"); + Assert.IsType(await shortSignature.Slack2()); + } + + // Slackの必須認証ヘッダーが欠落または重複した要求を拒否することを確認する。 + [Fact] + public async Task SlackRejectsMissingOrRepeatedRequiredHeaders() + { + var body = Encoding.UTF8.GetBytes("{}"); + var missing = CreateController(body); + Assert.IsType(await missing.Slack2()); + + var repeated = CreateController(body); + repeated.Request.Headers["X-Slack-Request-Timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + repeated.Request.Headers["X-Slack-Signature"] = new Microsoft.Extensions.Primitives.StringValues(new[] { "v0=first", "v0=second" }); + Assert.IsType(await repeated.Slack2()); + } + + // 極端な時刻値で差分計算がオーバーフローせず拒否されることを確認する。 + [Theory] + [InlineData("9223372036854775807")] + [InlineData("-9223372036854775808")] + [InlineData("not-an-integer")] + public async Task SlackRejectsExtremeOrInvalidTimestamps(string timestamp) + { + var body = Encoding.UTF8.GetBytes("{}"); + var controller = CreateController(body); + SetSlackHeaders(controller, timestamp, "v0=" + new string('a', 64)); + + Assert.IsType(await controller.Slack2()); + } + + // Slackの再送を署名検証後にだけ早期応答することを確認する。 + [Fact] + public async Task SlackRetryAcknowledgementRequiresValidAuthentication() + { + var body = Encoding.UTF8.GetBytes("not-json"); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + var rejected = CreateController(body); + SetSlackHeaders(rejected, timestamp, "v0=" + new string('a', 64)); + rejected.Request.Headers["X-Slack-Retry-Reason"] = "http_timeout"; + Assert.IsType(await rejected.Slack2()); + + var accepted = CreateController(body); + SetSlackHeaders(accepted, timestamp, SlackSignature(body, timestamp)); + accepted.Request.Headers["X-Slack-Retry-Reason"] = "http_timeout"; + Assert.IsType(await accepted.Slack2()); + } + + // 正しい署名が付いていてもJSONとして壊れたSlack本文を拒否することを確認する。 + [Fact] + public async Task SlackRejectsMalformedJsonAfterAuthentication() + { + var body = Encoding.UTF8.GetBytes("{"); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + var controller = CreateController(body); + SetSlackHeaders(controller, timestamp, SlackSignature(body, timestamp)); + + Assert.IsType(await controller.Slack2()); + } + + // 署名エラー時に本文や署名値をログへ出さないことを確認する。 + [Fact] + public async Task SlackAuthenticationFailureDoesNotLogBodyOrSignature() + { + const string bodyMarker = "private-slack-body-marker"; + const string signatureMarker = "private-slack-signature-marker"; + using var provider = new TestLoggerProvider(); + using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(provider)); + var body = Encoding.UTF8.GetBytes($"{{\"type\":\"url_verification\",\"challenge\":\"{bodyMarker}\"}}"); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + var controller = CreateController(body, logger: loggerFactory.CreateLogger()); + SetSlackHeaders(controller, timestamp, signatureMarker); + + Assert.IsType(await controller.Slack2()); + + var logs = string.Join("\n", provider.Messages); + Assert.DoesNotContain(bodyMarker, logs, StringComparison.Ordinal); + Assert.DoesNotContain(signatureMarker, logs, StringComparison.Ordinal); + } + + // LINEの署名はBOMを含む受信時バイト列で検証し、そのバイト列をキューへ渡すことを確認する。 + [Fact] + public async Task LinePreservesRawBomBodyAfterSignatureValidation() + { + var json = Encoding.UTF8.GetBytes("{\"events\":[]}"); + var body = new byte[] { 0xEF, 0xBB, 0xBF }.Concat(json).ToArray(); + var queue = new LineWebhookQueue(4); + var controller = CreateController(body, queue); + controller.Request.Headers["X-Line-Signature"] = LineSignature(body); + + Assert.IsType(await controller.LineAsync()); + Assert.Equal(body, (await ReadNextAsync(queue)).Body); + } + + // LINEの必須署名ヘッダーが欠落または重複した要求を拒否することを確認する。 + [Fact] + public async Task LineRejectsMissingOrRepeatedSignatureHeaders() + { + var body = Encoding.UTF8.GetBytes("{\"events\":[]}"); + var missing = CreateController(body); + Assert.IsType(await missing.LineAsync()); + + var repeated = CreateController(body); + repeated.Request.Headers["X-Line-Signature"] = new Microsoft.Extensions.Primitives.StringValues(new[] { LineSignature(body), LineSignature(body) }); + Assert.IsType(await repeated.LineAsync()); + } + + // 正しいLINE署名が付いていても構文不正なJSONをキューへ登録しないことを確認する。 + [Fact] + public async Task LineRejectsMalformedJsonAfterAuthentication() + { + var body = Encoding.UTF8.GetBytes("{"); + var queue = new LineWebhookQueue(4); + var controller = CreateController(body, queue); + controller.Request.Headers["X-Line-Signature"] = LineSignature(body); + + Assert.IsType(await controller.LineAsync()); + + var marker = new LineWebhookRequest(Encoding.UTF8.GetBytes("marker"), "marker-host"); + Assert.True(queue.TryEnqueue(marker)); + Assert.Equal(marker, await ReadNextAsync(queue)); + } + + // Content-Lengthの有無にかかわらずLINE本文を1MiBへ制限することを確認する。 + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task LineRejectsOversizedBodyWithOrWithoutContentLength(bool hasContentLength) + { + var body = new byte[MaximumBodySize + 1]; + var controller = CreateController(body); + controller.Request.Headers["X-Line-Signature"] = LineSignature(body); + if (hasContentLength) + { + controller.Request.ContentLength = body.Length; + } + + var result = Assert.IsType(await controller.LineAsync()); + + Assert.Equal(StatusCodes.Status413PayloadTooLarge, result.StatusCode); + } + + // Slack本文にもContent-Lengthなしで1MiBの上限を適用することを確認する。 + [Fact] + public async Task SlackRejectsOversizedBodyWithoutContentLength() + { + var body = new byte[MaximumBodySize + 1]; + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + var controller = CreateController(body); + SetSlackHeaders(controller, timestamp, SlackSignature(body, timestamp)); + + var result = Assert.IsType(await controller.Slack2()); + + Assert.Equal(StatusCodes.Status413PayloadTooLarge, result.StatusCode); + } + + // LINEの本文が上限ちょうどなら正常に受理され、バイト列も保たれることを確認する。 + [Fact] + public async Task LineAcceptsBodyAtMaximumSize() + { + var prefix = Encoding.UTF8.GetBytes("{\"events\":[]}"); + var body = new byte[MaximumBodySize]; + Array.Copy(prefix, body, prefix.Length); + Array.Fill(body, (byte)' ', prefix.Length, body.Length - prefix.Length); + var queue = new LineWebhookQueue(4); + var controller = CreateController(body, queue); + controller.Request.Headers["X-Line-Signature"] = LineSignature(body); + + Assert.IsType(await controller.LineAsync()); + Assert.Equal(body, (await ReadNextAsync(queue)).Body); + } + + // LINE送信本文と応答本文、Slack本文を処理ログへ記録しないことを確認する。 + [Fact] + public async Task SlackForwardingLogsOnlyLineResponseStatus() + { + const string bodyMarker = "private-forwarded-text-marker"; + const string nameMarker = "private-user-name-marker"; + const string responseMarker = "private-line-response-marker"; + var handler = new DelegateHandler((request, _) => + { + if (request.RequestUri!.AbsolutePath.Contains("users.profile.get", StringComparison.Ordinal)) + { + return Task.FromResult(JsonResponse(HttpStatusCode.OK, "{\"profile\":{\"display_name\":\"" + nameMarker + "\",\"image_512\":\"https://private.example.test/icon\"}}")); + } + + return Task.FromResult(JsonResponse(HttpStatusCode.OK, responseMarker)); + }); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://line.example.test/") }; + var queue = new LineWebhookQueue(4); + var channels = new SlackChannels + { + Channels = [new SlackChannel { Name = "slack", TeamId = "team", ChannelId = "channel" }] + }; + var lineChannels = new LineChannels { Channels = [new LineChannel { Name = "line", Id = "line-target" }] }; + var bridges = new SlackLineBridges { Bridges = [new global::SlackLineBridge.Models.Configurations.SlackLineBridge { Slack = "slack", Line = "line" }] }; + using var provider = new TestLoggerProvider(); + using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(provider)); + var controller = CreateController( + Encoding.UTF8.GetBytes("{}"), + queue, + loggerFactory.CreateLogger(), + new TestHttpClientFactory(client), + channels, + lineChannels, + bridges); + var body = Encoding.UTF8.GetBytes("{\"type\":\"event_callback\",\"team_id\":\"team\",\"event\":{\"type\":\"message\",\"channel\":\"channel\",\"text\":\"" + bodyMarker + "\",\"user\":\"user-id\"}}"); + controller.Request.Body = new MemoryStream(body, writable: false); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + SetSlackHeaders(controller, timestamp, SlackSignature(body, timestamp)); + + Assert.IsType(await controller.Slack2()); + + var logs = string.Join("\n", provider.Messages); + Assert.DoesNotContain(bodyMarker, logs, StringComparison.Ordinal); + Assert.DoesNotContain(nameMarker, logs, StringComparison.Ordinal); + Assert.DoesNotContain(responseMarker, logs, StringComparison.Ordinal); + Assert.Contains("OK", logs, StringComparison.Ordinal); + } + + // リンクラベルの有無と複数リンク、改行を含む本文の変換結果を確認する。 + [Fact] + public async Task SlackLinkReplacementHandlesLabelsMultipleLinksAndNewlines() + { + const string text = "first \nsecond \nthird "; + string? lineRequestJson = null; + var handler = new DelegateHandler(async (request, cancellationToken) => + { + if (request.RequestUri!.AbsolutePath.Contains("users.profile.get", StringComparison.Ordinal)) + { + return JsonResponse(HttpStatusCode.OK, "{\"profile\":{\"display_name\":\"Test User\",\"image_512\":\"https://private.example.test/icon\"}}"); + } + + lineRequestJson = await request.Content!.ReadAsStringAsync(cancellationToken); + return JsonResponse(HttpStatusCode.OK, "ok"); + }); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://line.example.test/") }; + var channels = new SlackChannels + { + Channels = [new SlackChannel { Name = "slack", TeamId = "team", ChannelId = "channel" }] + }; + var lineChannels = new LineChannels { Channels = [new LineChannel { Name = "line", Id = "line-target" }] }; + var bridges = new SlackLineBridges + { + Bridges = [new global::SlackLineBridge.Models.Configurations.SlackLineBridge { Slack = "slack", Line = "line" }] + }; + var queue = new LineWebhookQueue(4); + var controller = CreateController( + Encoding.UTF8.GetBytes("{}"), + queue, + clientFactory: new TestHttpClientFactory(client), + slackChannels: channels, + lineChannels: lineChannels, + bridges: bridges); + var body = JsonSerializer.SerializeToUtf8Bytes(new + { + type = "event_callback", + team_id = "team", + @event = new { type = "message", channel = "channel", text, user = "profile-user" } + }); + controller.Request.Body = new MemoryStream(body, writable: false); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + SetSlackHeaders(controller, timestamp, SlackSignature(body, timestamp)); + + Assert.IsType(await controller.Slack2()); + Assert.NotNull(lineRequestJson); + using var lineRequest = JsonDocument.Parse(lineRequestJson!); + var forwardedText = lineRequest.RootElement.GetProperty("messages")[0].GetProperty("text").GetString(); + Assert.Equal( + "first https://one.example/path\nsecond https://two.example/path\nthird https://three.example/path", + forwardedText); + } + + private static WebhookController CreateController( + byte[] body, + LineWebhookQueue? queue = null, + ILogger? logger = null, + IHttpClientFactory? clientFactory = null, + SlackChannels? slackChannels = null, + LineChannels? lineChannels = null, + SlackLineBridges? bridges = null) + { + queue ??= new LineWebhookQueue(4); + slackChannels ??= new SlackChannels { Channels = [] }; + lineChannels ??= new LineChannels { Channels = [] }; + bridges ??= new SlackLineBridges { Bridges = [] }; + clientFactory ??= new TestHttpClientFactory(new HttpClient(new DelegateHandler((_, _) => + Task.FromResult(JsonResponse(HttpStatusCode.OK, "{}"))))); + var service = new LineMessageProcessingService( + new TestOptions(slackChannels), + new TestOptions(lineChannels), + new TestOptions(bridges), + clientFactory, + queue, + new LineChannelSecret(LineSecret), + NullLogger.Instance); + var options = new JsonSerializerOptions(); + options.EnableDynamicTypes(); + + var context = new DefaultHttpContext(); + context.Request.Body = new MemoryStream(body, writable: false); + context.Request.Host = new HostString("bridge.example"); + var controller = new WebhookController( + logger ?? NullLogger.Instance, + new TestOptions(slackChannels), + new TestOptions(lineChannels), + new TestOptions(bridges), + service, + queue, + clientFactory, + new LineChannelSecret(LineSecret), + new SlackSigningSecret(SlackSecret), + options); + controller.ControllerContext = new ControllerContext { HttpContext = context }; + return controller; + } + + private static void SetSlackHeaders(WebhookController controller, string timestamp, string signature) + { + controller.Request.Headers["X-Slack-Request-Timestamp"] = timestamp; + controller.Request.Headers["X-Slack-Signature"] = signature; + } + + private static string SlackSignature(byte[] body, string timestamp) + { + var prefix = Encoding.UTF8.GetBytes($"v0:{timestamp}:"); + var signedBody = new byte[prefix.Length + body.Length]; + Buffer.BlockCopy(prefix, 0, signedBody, 0, prefix.Length); + Buffer.BlockCopy(body, 0, signedBody, prefix.Length, body.Length); + return "v0=" + Crypt.GetHMACHex(signedBody, SlackSecret); + } + + private static string LineSignature(byte[] body) => Convert.ToBase64String(Crypt.CalcHMAC(body, LineSecret)); + + private static async Task ReadNextAsync(LineWebhookQueue queue) + { + await using var reader = queue.ReadAllAsync(CancellationToken.None).GetAsyncEnumerator(); + Assert.True(await reader.MoveNextAsync()); + return reader.Current; + } + + private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, string content) => new(statusCode) + { + Content = new StringContent(content, Encoding.UTF8, "application/json") + }; + + private sealed class TestOptions(T value) : IOptionsSnapshot, IOptionsMonitor where T : class + { + public T Value => value; + public T CurrentValue => value; + public T Get(string? name) => value; + public IDisposable? OnChange(Action listener) => null; + } + + private sealed class TestHttpClientFactory(HttpClient client) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => client; + } + + private sealed class DelegateHandler(Func> sendAsync) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + sendAsync(request, cancellationToken); + } + + private sealed class TestLoggerProvider : ILoggerProvider + { + private readonly ConcurrentQueue _messages = new(); + public string[] Messages => _messages.ToArray(); + public ILogger CreateLogger(string categoryName) => new TestLogger(_messages); + public void Dispose() { } + + private sealed class TestLogger(ConcurrentQueue messages) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + messages.Enqueue(formatter(state, exception)); + } + } +} diff --git a/SlackLineBridge/Controllers/ProxyController.cs b/SlackLineBridge/Controllers/ProxyController.cs index 381530a..0a02f50 100644 --- a/SlackLineBridge/Controllers/ProxyController.cs +++ b/SlackLineBridge/Controllers/ProxyController.cs @@ -30,13 +30,13 @@ SlackSigningSecret slackSigningSecret [HttpGet("line/{token}/{id}")] public async Task Line(string id, string token) { - if (token != Crypt.GetHMACHex(id, _lineChannelSecret)) + if (!Crypt.FixedTimeEqualsAscii(Crypt.GetHMACHex(id, _lineChannelSecret), token)) { return new StatusCodeResult((int)HttpStatusCode.Forbidden); } var client = clientFactory.CreateClient("Line"); - var url = $"https://api-data.line.me/v2/bot/message/{id}/content"; + var url = $"https://api-data.line.me/v2/bot/message/{Uri.EscapeDataString(id)}/content"; return await ProxyContent(client, url, MediaCacheSource.Line, id); } @@ -45,12 +45,12 @@ public async Task Line(string id, string token) public async Task Slack(string encodedUrl, string token) { var url = HttpUtility.UrlDecode(encodedUrl); - if (token != Crypt.GetHMACHex(url, _slackSigningSecret)) + if (!Crypt.FixedTimeEqualsAscii(Crypt.GetHMACHex(url, _slackSigningSecret), token)) { return new StatusCodeResult((int)HttpStatusCode.Forbidden); } - var client = clientFactory.CreateClient("Slack"); + var client = clientFactory.CreateClient("SlackMedia"); return await ProxyContent(client, url, MediaCacheSource.Slack, url); } @@ -91,6 +91,8 @@ private async Task ProxyContent(HttpClient client, string url, Me } Response.StatusCode = result.StatusCode; + Response.Headers["X-Content-Type-Options"] = "nosniff"; + Response.Headers["Content-Security-Policy"] = "sandbox; default-src 'none'"; if (!result.EnableRangeProcessing && result.ContentLength.HasValue) { Response.ContentLength = result.ContentLength.Value; diff --git a/SlackLineBridge/Controllers/WebhookController.cs b/SlackLineBridge/Controllers/WebhookController.cs index d441076..51efbaa 100644 --- a/SlackLineBridge/Controllers/WebhookController.cs +++ b/SlackLineBridge/Controllers/WebhookController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; @@ -8,12 +9,13 @@ using System.Text; using System.Text.Json; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using System.Web; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; using SlackLineBridge.Models.Configurations; using SlackLineBridge.Services; using SlackLineBridge.Utils; @@ -35,6 +37,9 @@ public partial class WebhookController( SlackSigningSecret slackSigningSecret, JsonSerializerOptions jsonOptions) : ControllerBase { + private const int MaximumWebhookBodySize = 1024 * 1024; + private const int SlackTimestampToleranceSeconds = 5 * 60; + private readonly SlackChannels _slackChannels = slackChannels.Value; private readonly LineChannels _lineChannels = lineChannels.Value; private readonly SlackLineBridges _bridges = bridges.Value; @@ -46,89 +51,194 @@ public partial class WebhookController( [HttpPost("/slack2")] public async Task Slack2() { - //再送を無視する - if (Request.Headers.TryGetValue("X-Slack-Retry-Reason", out StringValues reason) && reason.First() == "http_timeout") + if (!TryGetSingleHeaderValue("X-Slack-Request-Timestamp", out var timestampHeader) + || !TryGetSingleHeaderValue("X-Slack-Signature", out var providedSignature) + || !long.TryParse(timestampHeader, NumberStyles.Integer, CultureInfo.InvariantCulture, out var timestamp)) { - return Ok(); + return BadRequest(); + } + + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (timestamp < now - SlackTimestampToleranceSeconds || timestamp > now + SlackTimestampToleranceSeconds) + { + return BadRequest(); + } + + var body = await ReadBoundedBodyAsync(Request, HttpContext.RequestAborted); + if (body == null) + { + return StatusCode(StatusCodes.Status413PayloadTooLarge); + } + + var signaturePrefix = Encoding.UTF8.GetBytes($"v0:{timestampHeader}:"); + var signedBody = new byte[signaturePrefix.Length + body.Length]; + Buffer.BlockCopy(signaturePrefix, 0, signedBody, 0, signaturePrefix.Length); + Buffer.BlockCopy(body, 0, signedBody, signaturePrefix.Length, body.Length); + var expectedSignature = $"v0={Crypt.GetHMACHex(signedBody, _slackSigningSecret)}"; + if (!Crypt.FixedTimeEqualsAscii(expectedSignature, providedSignature)) + { + logger.LogInformation("Slack request rejected because its signature is invalid."); + return BadRequest(); } - using var reader = new StreamReader(Request.Body); - var json = await reader.ReadToEndAsync(); - logger.LogInformation("Processing request from Slack: {json}", json); + // Slackの再送応答は、署名と時刻を検証した後に返す。 + if (Request.Headers.ContainsKey("X-Slack-Retry-Reason")) + { + if (!TryGetSingleHeaderValue("X-Slack-Retry-Reason", out var retryReason)) + { + return BadRequest(); + } - var timestampStr = Request.Headers["X-Slack-Request-Timestamp"].First(); - if (long.TryParse(timestampStr, out var timestamp)) - if (Math.Abs(DateTimeOffset.Now.ToUnixTimeSeconds() - timestamp) <= 60 * 5) + if (retryReason == "http_timeout") { - var sigBaseStr = $"v0:{timestamp}:{json}"; - var signature = $"v0={Crypt.GetHMACHex(sigBaseStr, _slackSigningSecret)}"; - var slackSignature = Request.Headers["X-Slack-Signature"].First(); + return Ok(); + } + } - logger.LogDebug("Slack signature check (expected:{slackSignature}, calculated:{signature})", slackSignature, signature); - if (signature == slackSignature) + string json; + dynamic data; + try + { + json = DecodeJsonBody(body); + using var document = JsonDocument.Parse(json); + data = JsonSerializer.Deserialize(json, jsonOptions); + } + catch (JsonException) + { + return BadRequest(); + } + catch (DecoderFallbackException) + { + return BadRequest(); + } + + if (data == null) + { + return BadRequest(); + } + + logger.LogInformation("Processing authenticated request from Slack."); + string type = data.type; + switch (type) + { + case "url_verification": + string challenge = data.challenge; + return Ok(challenge); + case "event_callback": + string eventType = data.@event.type; + switch (eventType) { - // the request came from Slack! - dynamic data = JsonSerializer.Deserialize(json, jsonOptions); - string type = data.type; - switch (type) - { - case "url_verification": - string challenge = data.challenge; - return Ok(challenge); - case "event_callback": - string eventType = data.@event.type; - switch (eventType) + case "message": + if (data.@event.subtype == "bot_message") + { + return Ok(); + } + var slackChannels = _slackChannels.Channels; + string teamId = data.team_id; + string channelId = data.@event.channel; + var slackChannel = slackChannels.FirstOrDefault(x => x.TeamId == teamId && x.ChannelId == channelId); + if (slackChannel == null) + { + logger.LogInformation("Ignoring a message from an unconfigured Slack channel."); + return Ok(); + } + + string text = data.@event.text; + string userId = data.@event.user; + var (userName, icon) = await GetSlackUserNameAndIcon(userId); + + JsonDynamicArray files = data.@event.files; + SlackFile[] slackFiles = null; + + if ((files?.Count ?? 0) > 0) + { + slackFiles = files.Cast().Select(x => new SlackFile { - case "message": - if (data.@event.subtype == "bot_message") - { - return Ok(); - } - var slackChannels = _slackChannels.Channels; - string teamId = data.team_id; - string channelId = data.@event.channel; - var slackChannel = slackChannels.FirstOrDefault(x => x.TeamId == teamId && x.ChannelId == channelId); - if (slackChannel == null) - { - logger.LogInformation("message from unknown slack channel: {teamId}/{channelId}", teamId, channelId); - return Ok(); - } - - string text = data.@event.text; - string userId = data.@event.user; - var (userName, icon) = await GetSlackUserNameAndIcon(userId); - - JsonDynamicArray files = data.@event.files; - SlackFile[] slackFiles = null; - - if ((files?.Count ?? 0) > 0) - { - slackFiles = files.Cast().Select(x => new SlackFile - { - urlPrivate = x.url_private, - thumb360 = x.thumb_360, - mimeType = x.mimetype - }).ToArray(); - } - - return await PushToLine(Request.Host.ToString(), slackChannel, icon, userName, text, slackFiles); - } - break; - } - } - else - { - logger.LogInformation("Slack signature missmatch."); + urlPrivate = x.url_private, + thumb360 = x.thumb_360, + mimeType = x.mimetype + }).ToArray(); + } + + return await PushToLine(Request.Host.ToString(), slackChannel, icon, userName, text, slackFiles); } - } + break; + } return BadRequest(); } + private bool TryGetSingleHeaderValue(string headerName, out string value) + { + var values = Request.Headers[headerName]; + if (values.Count == 1 && !string.IsNullOrEmpty(values[0])) + { + value = values[0]; + return true; + } + + value = null; + return false; + } + + private static async Task ReadBoundedBodyAsync(HttpRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (request.ContentLength > MaximumWebhookBodySize) + { + return null; + } + + using var bodyBuffer = new MemoryStream(); + var buffer = new byte[81920]; + while (true) + { + var remainingWithOverflowCheck = MaximumWebhookBodySize + 1 - (int)bodyBuffer.Length; + var read = await request.Body.ReadAsync( + buffer.AsMemory(0, Math.Min(buffer.Length, remainingWithOverflowCheck)), + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (read == 0) + { + return bodyBuffer.ToArray(); + } + + if (bodyBuffer.Length + read > MaximumWebhookBodySize) + { + return null; + } + + bodyBuffer.Write(buffer, 0, read); + } + } + + private static string DecodeJsonBody(byte[] body) + { + var offset = HasUtf8Bom(body) ? 3 : 0; + return new UTF8Encoding(false, true).GetString(body, offset, body.Length - offset); + } + + private static bool HasUtf8Bom(byte[] body) => + body.Length >= 3 && body[0] == 0xEF && body[1] == 0xBB && body[2] == 0xBF; + + private static bool IsValidJson(byte[] body) + { + try + { + var offset = HasUtf8Bom(body) ? 3 : 0; + using var document = JsonDocument.Parse(body.AsMemory(offset)); + return true; + } + catch (JsonException) + { + return false; + } + } + private async Task<(string userName, string icon)> GetSlackUserNameAndIcon(string userId) { var client = clientFactory.CreateClient("Slack"); - var result = await client.GetAsync($"https://slack.com/api/users.profile.get?user={userId}"); + using var result = await client.GetAsync($"https://slack.com/api/users.profile.get?user={Uri.EscapeDataString(userId)}"); var json = await result.Content.ReadAsStringAsync(); dynamic data = JsonSerializer.Deserialize(json, jsonOptions); (string name, string icon) = (data.profile.display_name, data.profile.image_512); @@ -144,7 +254,7 @@ private record SlackFile public string mimeType { get; set; } } - [GeneratedRegex(@"(\<(?http[^\|\>]+)\|?.*?\>)")] + [GeneratedRegex(@"\<(?http[^|>]+)[^>\n]*\>", RegexOptions.NonBacktracking)] private static partial Regex UrlRegex(); private async Task PushToLine(string host, SlackChannel slackChannel, string userIconUrl, string userName, string text, SlackFile[] files = null) @@ -214,9 +324,8 @@ private async Task PushToLine(string host, SlackChannel slackChan messages = messages.ToArray() }; var jsonStr = JsonSerializer.Serialize(json); - logger.LogInformation("Push message to LINE (using replyToken): {jsonStr}", jsonStr); - var result = await client.PostAsync($"message/reply", new StringContent(jsonStr, Encoding.UTF8, "application/json")); - logger.LogInformation("LINE API result [{result.StatusCode}]: {result.Content}", result.StatusCode, await result.Content.ReadAsStringAsync()); + using var result = await client.PostAsync($"message/reply", new StringContent(jsonStr, Encoding.UTF8, "application/json")); + logger.LogInformation("LINE reply request returned status {StatusCode}.", result.StatusCode); if (result.IsSuccessStatusCode) { continue; @@ -231,9 +340,8 @@ private async Task PushToLine(string host, SlackChannel slackChan messages = messages.ToArray() }; var jsonStr = JsonSerializer.Serialize(json); - logger.LogInformation("Push message to LINE: {jsonStr}", jsonStr); - var result = await client.PostAsync($"message/push", new StringContent(jsonStr, Encoding.UTF8, "application/json")); - logger.LogInformation("LINE API result [{result.StatusCode}]: {result.Content}", result.StatusCode, await result.Content.ReadAsStringAsync()); + using var result = await client.PostAsync($"message/push", new StringContent(jsonStr, Encoding.UTF8, "application/json")); + logger.LogInformation("LINE push request returned status {StatusCode}.", result.StatusCode); } } } @@ -243,22 +351,31 @@ private async Task PushToLine(string host, SlackChannel slackChan [HttpPost("/line")] public async Task LineAsync() { - if (!Request.Headers.TryGetValue("X-Line-Signature", out StringValues signature) || StringValues.IsNullOrEmpty(signature)) + if (!TryGetSingleHeaderValue("X-Line-Signature", out var signature)) { - logger.LogInformation("LINE webhook rejected because its signature header is missing."); + logger.LogInformation("LINE webhook rejected because its signature header is missing or repeated."); return BadRequest(); } - using var bodyStream = new MemoryStream(); - await Request.Body.CopyToAsync(bodyStream, HttpContext.RequestAborted); - var body = bodyStream.ToArray(); + var body = await ReadBoundedBodyAsync(Request, HttpContext.RequestAborted); + if (body == null) + { + return StatusCode(StatusCodes.Status413PayloadTooLarge); + } + HttpContext.RequestAborted.ThrowIfCancellationRequested(); - if (!IsValidLineSignature(body, signature.ToString())) + if (!IsValidLineSignature(body, signature)) { logger.LogInformation("LINE webhook rejected because its signature is invalid."); return BadRequest(); } + if (!IsValidJson(body)) + { + logger.LogInformation("LINE webhook rejected because its JSON body is invalid."); + return BadRequest(); + } + HttpContext.RequestAborted.ThrowIfCancellationRequested(); if (!_lineRequestQueue.TryEnqueue(new LineWebhookRequest(body, Request.Host.ToString()))) { diff --git a/SlackLineBridge/Models/Configurations/LineChannelSecret.cs b/SlackLineBridge/Models/Configurations/LineChannelSecret.cs index c0a9084..799baf9 100644 --- a/SlackLineBridge/Models/Configurations/LineChannelSecret.cs +++ b/SlackLineBridge/Models/Configurations/LineChannelSecret.cs @@ -1,13 +1,18 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; namespace SlackLineBridge.Models.Configurations { public class LineChannelSecret { public string Secret { get; } - public LineChannelSecret(string secret) => Secret = secret; + public LineChannelSecret(string secret) + { + if (string.IsNullOrWhiteSpace(secret)) + { + throw new ArgumentException("LINE channel secret must be configured.", nameof(secret)); + } + + Secret = secret; + } } } diff --git a/SlackLineBridge/Models/Configurations/MediaCacheOptions.cs b/SlackLineBridge/Models/Configurations/MediaCacheOptions.cs index b157b88..d03ed3b 100644 --- a/SlackLineBridge/Models/Configurations/MediaCacheOptions.cs +++ b/SlackLineBridge/Models/Configurations/MediaCacheOptions.cs @@ -16,6 +16,8 @@ public sealed class MediaCacheOptions public string KeyPrefix { get; set; } = "slack-line-bridge/media"; + public long MaxObjectBytes { get; set; } = 256L * 1024 * 1024; + public bool ForcePathStyle { get; set; } public string AccessKey { get; set; } @@ -30,6 +32,11 @@ public IReadOnlyList GetValidationErrors() return errors; } + if (MaxObjectBytes <= 0) + { + errors.Add("MediaCache:MaxObjectBytes must be greater than zero when the media cache is enabled."); + } + if (string.IsNullOrWhiteSpace(Region)) { errors.Add("MediaCache:Region is required when the media cache is enabled."); diff --git a/SlackLineBridge/Models/Configurations/SlackSigningSecret.cs b/SlackLineBridge/Models/Configurations/SlackSigningSecret.cs index 30fb0a2..46d61a5 100644 --- a/SlackLineBridge/Models/Configurations/SlackSigningSecret.cs +++ b/SlackLineBridge/Models/Configurations/SlackSigningSecret.cs @@ -1,13 +1,18 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; namespace SlackLineBridge.Models.Configurations { public class SlackSigningSecret { public string Secret { get; } - public SlackSigningSecret(string secret) => Secret = secret; + public SlackSigningSecret(string secret) + { + if (string.IsNullOrWhiteSpace(secret)) + { + throw new ArgumentException("Slack signing secret must be configured.", nameof(secret)); + } + + Secret = secret; + } } } diff --git a/SlackLineBridge/Program.cs b/SlackLineBridge/Program.cs index 001f668..0f660db 100644 --- a/SlackLineBridge/Program.cs +++ b/SlackLineBridge/Program.cs @@ -37,7 +37,9 @@ public static IHostBuilder CreateHostBuilder(string[] args) { o.Dsn = sentryDsn; o.TracesSampleRate = 0; - o.SendDefaultPii = true; + o.SendDefaultPii = false; + o.SetBeforeSend(SentryPrivacyFilter.RemoveRequestMetadata); + o.SetBeforeBreadcrumb(SentryPrivacyFilter.DropHttpBreadcrumb); }); } webBuilder.UseStartup(); diff --git a/SlackLineBridge/Properties/AssemblyInfo.cs b/SlackLineBridge/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..17b7d65 --- /dev/null +++ b/SlackLineBridge/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SlackLineBridge.Tests")] diff --git a/SlackLineBridge/SentryPrivacyFilter.cs b/SlackLineBridge/SentryPrivacyFilter.cs new file mode 100644 index 0000000..a997452 --- /dev/null +++ b/SlackLineBridge/SentryPrivacyFilter.cs @@ -0,0 +1,23 @@ +using System; +using Sentry; + +namespace SlackLineBridge +{ + internal static class SentryPrivacyFilter + { + public static SentryEvent RemoveRequestMetadata(SentryEvent sentryEvent) + { + // Request.Url can contain the signed proxy token in its path. + // The SDK may recreate an empty request on access; its serializer omits that empty object. + sentryEvent.Request = null; + return sentryEvent; + } + + public static Breadcrumb DropHttpBreadcrumb(Breadcrumb breadcrumb) + { + return string.Equals(breadcrumb.Type, "http", StringComparison.OrdinalIgnoreCase) + ? null + : breadcrumb; + } + } +} diff --git a/SlackLineBridge/Services/MediaProxyService.cs b/SlackLineBridge/Services/MediaProxyService.cs index 5120859..587cd0e 100644 --- a/SlackLineBridge/Services/MediaProxyService.cs +++ b/SlackLineBridge/Services/MediaProxyService.cs @@ -160,6 +160,12 @@ private async Task FetchAndCacheAsync( return new MediaProxyResponse(statusCode, passthrough, contentType, contentLength); } + if (response.Content.Headers.ContentLength is long declaredLength + && declaredLength > _options.MaxObjectBytes) + { + return new MediaProxyResponse((int)HttpStatusCode.BadGateway); + } + var responseContentType = GetContentType(response.Content.Headers.ContentType); tempPath = Path.Combine(Path.GetTempPath(), $"slacklinebridge-mediacache-{Guid.NewGuid():N}.tmp"); try @@ -179,7 +185,15 @@ private async Task FetchAndCacheAsync( await using (var upstreamStream = await response.Content.ReadAsStreamAsync(cancellationToken)) { - await CopyToTempFileAsync(upstreamStream, tempFile, cancellationToken); + var copiedWithinLimit = await CopyToTempFileAsync( + upstreamStream, + tempFile, + _options.MaxObjectBytes, + cancellationToken); + if (!copiedWithinLimit) + { + return new MediaProxyResponse((int)HttpStatusCode.BadGateway); + } } try @@ -253,15 +267,34 @@ private static MediaProxyResponse RangeNotSatisfiable(MediaCacheRangeNotSatisfia supportsRangeRequests: true); } - private static async Task CopyToTempFileAsync(Stream source, FileStream destination, CancellationToken cancellationToken) + private static async Task CopyToTempFileAsync( + Stream source, + FileStream destination, + long maxBytes, + CancellationToken cancellationToken) { + if (maxBytes <= 0) + { + return false; + } + var buffer = new byte[BufferSize]; + long totalBytes = 0; while (true) { - var bytesRead = await source.ReadAsync(buffer.AsMemory(), cancellationToken); + var remainingBytes = maxBytes - totalBytes; + var readSize = remainingBytes >= buffer.Length + ? buffer.Length + : (int)remainingBytes + 1; + var bytesRead = await source.ReadAsync(buffer.AsMemory(0, readSize), cancellationToken); if (bytesRead == 0) { - return; + return true; + } + + if (bytesRead > remainingBytes) + { + return false; } try @@ -272,6 +305,8 @@ private static async Task CopyToTempFileAsync(Stream source, FileStream destinat { throw new MediaCacheStorageException("Could not write a media cache temporary file.", exception); } + + totalBytes += bytesRead; } } diff --git a/SlackLineBridge/Services/SlackMediaHandler.cs b/SlackLineBridge/Services/SlackMediaHandler.cs new file mode 100644 index 0000000..f3ab872 --- /dev/null +++ b/SlackLineBridge/Services/SlackMediaHandler.cs @@ -0,0 +1,108 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace SlackLineBridge.Services +{ + public sealed class SlackMediaHandler : DelegatingHandler + { + private const int MaximumRedirects = 5; + private readonly string _slackAccessToken; + + public SlackMediaHandler(string slackAccessToken, HttpMessageHandler innerHandler = null) + { + if (string.IsNullOrWhiteSpace(slackAccessToken)) + { + throw new ArgumentException("Slack access token must be configured.", nameof(slackAccessToken)); + } + + _slackAccessToken = slackAccessToken; + if (innerHandler != null) + { + InnerHandler = innerHandler; + } + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method != HttpMethod.Get || request.Content != null || request.RequestUri == null || !request.RequestUri.IsAbsoluteUri) + { + throw new HttpRequestException("Slack media requests must use an absolute GET URL."); + } + + var currentUri = request.RequestUri; + var redirectsFollowed = 0; + while (true) + { + using var outgoingRequest = CreateRequest(request, currentUri); + var response = await base.SendAsync(outgoingRequest, cancellationToken); + if (!IsRedirect(response.StatusCode) || response.Headers.Location == null) + { + return response; + } + + if (redirectsFollowed >= MaximumRedirects) + { + response.Dispose(); + throw new HttpRequestException("Slack media redirect limit was exceeded."); + } + + if (!Uri.TryCreate(currentUri, response.Headers.Location, out var nextUri)) + { + response.Dispose(); + throw new HttpRequestException("Slack media redirect URL is invalid."); + } + + response.Dispose(); + currentUri = nextUri; + redirectsFollowed++; + } + } + + private HttpRequestMessage CreateRequest(HttpRequestMessage original, Uri uri) + { + var request = new HttpRequestMessage(HttpMethod.Get, uri) + { + Version = original.Version, + VersionPolicy = original.VersionPolicy + }; + + foreach (var header in original.Headers) + { + if (IsSafeToForward(header.Key)) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + if (uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && uri.IdnHost.Equals("files.slack.com", StringComparison.OrdinalIgnoreCase)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _slackAccessToken); + } + + return request; + } + + private static bool IsSafeToForward(string headerName) + { + return headerName.Equals("Accept", StringComparison.OrdinalIgnoreCase) + || headerName.Equals("Accept-Encoding", StringComparison.OrdinalIgnoreCase) + || headerName.Equals("If-Range", StringComparison.OrdinalIgnoreCase) + || headerName.Equals("Range", StringComparison.OrdinalIgnoreCase) + || headerName.Equals("User-Agent", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsRedirect(HttpStatusCode statusCode) + { + return statusCode is HttpStatusCode.MovedPermanently + or HttpStatusCode.Redirect + or HttpStatusCode.SeeOther + or HttpStatusCode.TemporaryRedirect + or HttpStatusCode.PermanentRedirect; + } + } +} diff --git a/SlackLineBridge/SlackLineBridge.csproj b/SlackLineBridge/SlackLineBridge.csproj index 0d8a276..33f7ec2 100644 --- a/SlackLineBridge/SlackLineBridge.csproj +++ b/SlackLineBridge/SlackLineBridge.csproj @@ -10,6 +10,8 @@ + + diff --git a/SlackLineBridge/Startup.cs b/SlackLineBridge/Startup.cs index 0dc84bb..bacbf16 100644 --- a/SlackLineBridge/Startup.cs +++ b/SlackLineBridge/Startup.cs @@ -1,6 +1,5 @@ using System; -using System.Collections.Generic; -using System.Linq; +using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Serialization.Samples; @@ -31,6 +30,13 @@ public Startup(IConfiguration configuration) // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { + ValidateRequiredSetting("lineChannelSecret"); + ValidateRequiredSetting("slackSigningSecret"); + ValidateRequiredSetting("lineAccessToken"); + ValidateRequiredSetting("slackAccessToken"); + + // HttpClientFactory logs request URLs by default. Slack webhook URLs contain a secret. + services.ConfigureHttpClientDefaults(builder => builder.RemoveAllLoggers()); services.AddLogging(x => { x.AddConfiguration(Configuration.GetSection("Logging")); @@ -57,6 +63,13 @@ public void ConfigureServices(IServiceCollection services) { c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Configuration["slackAccessToken"]); }); + services.AddHttpClient("SlackMedia") + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false + }) + .AddHttpMessageHandler(() => new SlackMediaHandler(Configuration["slackAccessToken"])); services.Configure(x => x.Channels = Configuration.GetSection("slackChannels").Get()); services.Configure(x => x.Channels = Configuration.GetSection("lineChannels").Get()); services.Configure(x => x.Bridges = Configuration.GetSection("slackLineBridges").Get()); @@ -107,5 +120,13 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) endpoints.MapControllers(); }); } + + private void ValidateRequiredSetting(string key) + { + if (string.IsNullOrWhiteSpace(Configuration[key])) + { + throw new InvalidOperationException($"Required configuration '{key}' must be a non-empty value."); + } + } } } diff --git a/SlackLineBridge/Utils/Crypt.cs b/SlackLineBridge/Utils/Crypt.cs index bbace71..85ca996 100644 --- a/SlackLineBridge/Utils/Crypt.cs +++ b/SlackLineBridge/Utils/Crypt.cs @@ -1,9 +1,6 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Security.Cryptography; using System.Text; -using System.Threading.Tasks; namespace SlackLineBridge.Utils { @@ -28,6 +25,31 @@ private static byte[] CalcHMAC(byte[] textBytes, byte[] keyBytes) public static string GetHMACBase64(string text, string key) => Convert.ToBase64String(CalcHMAC(text, key)); - public static string GetHMACHex(string text, string key) => BitConverter.ToString(CalcHMAC(text, key)).Replace("-", "").ToLower(); + public static string GetHMACHex(string text, string key) => Convert.ToHexString(CalcHMAC(text, key)).ToLowerInvariant(); + + public static string GetHMACHex(byte[] textBytes, string key) => Convert.ToHexString(CalcHMAC(textBytes, key)).ToLowerInvariant(); + + public static bool FixedTimeEqualsAscii(string expected, string provided) + { + if (expected == null || provided == null || expected.Length != provided.Length) + { + return false; + } + + var expectedBytes = new byte[expected.Length]; + var providedBytes = new byte[provided.Length]; + for (var index = 0; index < expected.Length; index++) + { + if (expected[index] > 0x7F || provided[index] > 0x7F) + { + return false; + } + + expectedBytes[index] = (byte)expected[index]; + providedBytes[index] = (byte)provided[index]; + } + + return CryptographicOperations.FixedTimeEquals(expectedBytes, providedBytes); + } } } diff --git a/SlackLineBridge/config.json.example b/SlackLineBridge/config.json.example index 6af2294..a1ab14a 100644 --- a/SlackLineBridge/config.json.example +++ b/SlackLineBridge/config.json.example @@ -25,6 +25,7 @@ "slackAccessToken": "", "MediaCache": { "Enabled": false, + "MaxObjectBytes": 268435456, "Endpoint": "", "Region": "us-east-1", "Bucket": "",