Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/SentenceStudio.Api/ImportEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ private static async Task<IResult> 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
{
Expand Down
69 changes: 69 additions & 0 deletions tests/SentenceStudio.Api.Tests/ImportEndpointsSsrfTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public class ImportEndpointsSsrfTests : IClassFixture<JwtBearerApiFactory>
{
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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Claim>
Expand All @@ -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));
Expand Down
Loading