From dc9fa81e61364cad9cfe77a28fc0972d704af7ac Mon Sep 17 00:00:00 2001 From: David Ortinau Date: Mon, 27 Apr 2026 18:36:35 -0500 Subject: [PATCH] Fix SSRF vulnerability: validate VideoUrl against YouTube allowlist in POST /api/imports Add a YouTube URL allowlist guard in StartImport before the import record is created or the pipeline is started. Any URL that is not an https:// URL on www.youtube.com, youtube.com, or youtu.be is rejected with 400 Bad Request. - ImportEndpoints.cs: insert 6-line guard after the null check (lines 67-72) - TestJwtGenerator.cs: add optional userProfileId parameter so integration tests can supply the claim required by the /api/imports endpoint - ImportEndpointsSsrfTests.cs: 13 new tests covering Azure IMDS, RFC-1918 addresses, loopback, non-https YouTube, non-YouTube HTTPS hosts, unparseable input, and three valid YouTube URLs that must pass the guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/SentenceStudio.Api/ImportEndpoints.cs | 7 ++ .../ImportEndpointsSsrfTests.cs | 69 +++++++++++++++++++ .../Infrastructure/TestJwtGenerator.cs | 6 ++ 3 files changed, 82 insertions(+) create mode 100644 tests/SentenceStudio.Api.Tests/ImportEndpointsSsrfTests.cs diff --git a/src/SentenceStudio.Api/ImportEndpoints.cs b/src/SentenceStudio.Api/ImportEndpoints.cs index c7cd546e..38ec007f 100644 --- a/src/SentenceStudio.Api/ImportEndpoints.cs +++ b/src/SentenceStudio.Api/ImportEndpoints.cs @@ -64,6 +64,13 @@ private static async Task StartImport( if (string.IsNullOrWhiteSpace(request.VideoUrl)) return Results.BadRequest("VideoUrl is required"); + if (!Uri.TryCreate(request.VideoUrl, UriKind.Absolute, out var videoUri) || + (videoUri.Host != "www.youtube.com" && videoUri.Host != "youtube.com" && videoUri.Host != "youtu.be") || + videoUri.Scheme != "https") + { + return Results.BadRequest("VideoUrl must be a valid YouTube URL."); + } + // Create the import record and return immediately var import = new VideoImport { diff --git a/tests/SentenceStudio.Api.Tests/ImportEndpointsSsrfTests.cs b/tests/SentenceStudio.Api.Tests/ImportEndpointsSsrfTests.cs new file mode 100644 index 00000000..c29a8e56 --- /dev/null +++ b/tests/SentenceStudio.Api.Tests/ImportEndpointsSsrfTests.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using SentenceStudio.Api.Tests.Infrastructure; + +namespace SentenceStudio.Api.Tests; + +/// +/// Verifies the SSRF allowlist guard on POST /api/imports. +/// Any URL that is not a valid https://youtube.com (or youtu.be) URL must be rejected +/// with 400 Bad Request before the server makes any outbound HTTP request. +/// +public class ImportEndpointsSsrfTests : IClassFixture +{ + private const string ImportsPath = "/api/imports"; + private const string TestUserProfileId = "ssrf-test-profile-001"; + + private readonly HttpClient _client; + + public ImportEndpointsSsrfTests(JwtBearerApiFactory factory) + { + _client = factory.CreateClient(); + + var token = TestJwtGenerator.GenerateToken(userProfileId: TestUserProfileId); + _client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token); + } + + [Theory] + [InlineData("http://169.254.169.254/metadata/instance")] // Azure IMDS + [InlineData("http://169.254.169.254/")] // IMDS root + [InlineData("http://10.0.0.1/internal")] // RFC-1918 internal + [InlineData("http://192.168.1.1/admin")] // RFC-1918 internal + [InlineData("http://localhost/evil")] // loopback + [InlineData("http://www.youtube.com/watch?v=dQw4w9WgXcQ")] // valid YouTube, non-https + [InlineData("https://evil.com/watch?v=dQw4w9WgXcQ")] // non-YouTube HTTPS host + [InlineData("ftp://www.youtube.com/watch?v=dQw4w9WgXcQ")] // valid host, wrong scheme + [InlineData("not-a-url-at-all")] // unparseable + [InlineData("")] // empty + public async Task StartImport_RejectsSsrfAndInvalidUrls(string badUrl) + { + var response = await _client.PostAsJsonAsync(ImportsPath, new + { + VideoUrl = badUrl, + Language = "Korean" + }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, + $"URL '{badUrl}' should be rejected before any outbound request is made"); + } + + [Theory] + [InlineData("https://www.youtube.com/watch?v=dQw4w9WgXcQ")] + [InlineData("https://youtube.com/watch?v=dQw4w9WgXcQ")] + [InlineData("https://youtu.be/dQw4w9WgXcQ")] + public async Task StartImport_AcceptsValidYouTubeUrls(string validUrl) + { + var response = await _client.PostAsJsonAsync(ImportsPath, new + { + VideoUrl = validUrl, + Language = "Korean" + }); + + // 202 Accepted means the guard passed; actual pipeline failure is expected in tests + // (no real YouTube API / AI service available). We only care that it is not 400. + response.StatusCode.Should().NotBe(HttpStatusCode.BadRequest, + $"'{validUrl}' is a valid YouTube URL and must pass the SSRF guard"); + } +} diff --git a/tests/SentenceStudio.Api.Tests/Infrastructure/TestJwtGenerator.cs b/tests/SentenceStudio.Api.Tests/Infrastructure/TestJwtGenerator.cs index 073e37f3..074bfaca 100644 --- a/tests/SentenceStudio.Api.Tests/Infrastructure/TestJwtGenerator.cs +++ b/tests/SentenceStudio.Api.Tests/Infrastructure/TestJwtGenerator.cs @@ -30,6 +30,7 @@ public static string GenerateToken( string? displayName = null, string? email = null, string? scopes = null, + string? userProfileId = null, TimeSpan? lifetime = null) { var claims = new List @@ -42,6 +43,11 @@ public static string GenerateToken( new(ClaimTypes.Email, email ?? DefaultEmail), }; + if (!string.IsNullOrWhiteSpace(userProfileId)) + { + claims.Add(new Claim("user_profile_id", userProfileId)); + } + if (!string.IsNullOrWhiteSpace(scopes)) { claims.Add(new Claim("scp", scopes));