How to use dotnet and Traefik to connect a legacy PHP application to AWS S3

One of the legacy systems that I needed to assist a client on, has a business-critical application written in PHP 5.2 hosted on a CentOS 6 VM. This host machine has an old version of OpenSSL and does not connect to many modern endpoints that require strong ciphers and TLS 1.2 or higher. Further, loading dependencies such as the AWS SDK to interact with the S3 buckets is even more difficult as the package managers no longer function. The key issues that I faced were:

  • Old version of TLS on the CentOS is lower than required by AWS SDK.
  • Loading the dependencies would be difficult as none of the package managers work.

My solution was to build a new dotnet application using minimal APIs, place a proxy server in front of it using Traefik, and then allow the legacy PHP application to make curl requests to the C# application, that will then interact directly with S3. The basic flow would look something like this:

graph LR;
    PHP--cURL request-->Traefik;
    Traefik--proxy-->dotnet;
    dotnet--http request-->S3
cURL request
proxy
http request
PHP
Traefik
dotnet
S3

The minimal APIs of the initial prototype are below:

program.cs
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using OpenTelemetry.Logs;
using OpenTelemetry.Resources;
using System.Text;
using Microsoft.AspNetCore.Authentication;
using Microsoft.OpenApi.Models;
using System.Security.Authentication;
using System.Net;
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.UseKestrel(options => {
    options.Limits.MaxResponseBufferSize = 52428800; // 50MB
    options.Listen(IPAddress.Any, 5000); // HTTP
    options.Listen(IPAddress.Any, 5001, listenOptions => // HTTPS
    {
        listenOptions.UseHttps(httpsOptions =>
        {
            // Generate a self-signed certificate
            var certReq = new CertificateRequest("CN=Kestrel", RSA.Create(2048), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
            var cert = certReq.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1));
            httpsOptions.ServerCertificate = cert;
            httpsOptions.SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13;
        });
    });
});

builder.Services.AddTransient<S3Client>();

builder.Services.AddAuthentication("BasicAuthentication")
        .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("BasicAuthentication", null);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.AddSecurityDefinition("basic", new OpenApiSecurityScheme
    {
        Name = "Authorization",
        Type = SecuritySchemeType.Http,
        Scheme = "basic",
        In = ParameterLocation.Header,
        Description = "Basic Authorization header using the Bearer scheme."
    });

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "basic"
                }
            },
            new string[] {}
        }
    });
});

builder.Logging.AddOpenTelemetry(logging =>
{
    var resourceBuilder = ResourceBuilder
        .CreateDefault()
        .AddService(builder.Environment.ApplicationName);

    logging.SetResourceBuilder(resourceBuilder)
        .AddConsoleExporter();
});

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService(serviceName: builder.Environment.ApplicationName))
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()
        .AddConsoleExporter((exporterOptions, metricReaderOptions) =>
        {
            metricReaderOptions.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 2000;
        }));

builder.Services.AddControllers();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

// Basic Authentication
app.UseMiddleware<BasicAuthMiddleware>();

app.MapPost("/upload", async (HttpContext context, string fileName) =>
{
    var authHeader = context.Request.Headers.Authorization.ToString().Split(' ')[1];
    if (!ValidateCredentials(authHeader, builder))
    {
        return Results.Unauthorized();
    }
    var s3Client = context.RequestServices.GetRequiredService<S3Client>();
    var file = context.Request.Form.Files[0];
    await s3Client.UploadFileAsync(file, fileName);
    return Results.Ok();
})
    .WithOpenApi()
    .WithDescription("Uploads a file to S3 bucket");

// Download the file from S3
app.MapGet("/download", async (HttpContext context, string fileName) =>
{
    var authHeader = context.Request.Headers.Authorization.ToString().Split(' ')[1];
    if (!ValidateCredentials(authHeader, builder))
    {
        return Results.Unauthorized();
    }
    var s3Client = context.RequestServices.GetRequiredService<S3Client>();
    var stream = await s3Client.DownloadFileAsync(fileName);
    return Results.File(stream, "application/octet-stream");
})
    .WithOpenApi()
    .WithDescription("Returns the file directly.");

// Download the valet URL
app.MapGet("/download-valet", (HttpContext context, string fileName) =>
{
    var authHeader = context.Request.Headers.Authorization.ToString().Split(' ')[1];
    if (!ValidateCredentials(authHeader, builder))
    {
        return Results.Unauthorized();
    }
    var s3Client = app.Services.GetRequiredService<S3Client>();
    var url = s3Client.GetValetUrl(fileName, null);
    return Results.Ok(new { url });
})
    .WithOpenApi()
    .WithDescription("Returns a time limited URL that can directly download the file.");

static bool ValidateCredentials(string authHeader, WebApplicationBuilder builder)
{
    var credentialBytes = Convert.FromBase64String(authHeader);
    var credentials = Encoding.UTF8.GetString(credentialBytes).Split(':');
    var username = credentials[0];
    var password = credentials[1];
    var appSettings = builder.Configuration.GetSection("BasicAuthSettings");
    var appUsername = appSettings["Username"];
    var appPassword = appSettings["Password"];
    return username == appUsername && password == appPassword;
}

app.MapControllers();
app.Run();
C#

This design does have a security risk as it uses basic authentication only because it is the only method that was supported by the existing libraries in the PHP code base. A proxy server such as Traefik is required so that the PHP application can interact with a signed certificate (Let’s Encrypt) and a version of TLS with a cipher that is supported. Traefik has been configured to use an IP whitelist so that requests forwarded to the dotnet application only are sent if they originated from the CentOS server.

Additional code that will help to make a complete project, most was generated using Github Copilot.

BasicAuthenticationHandler.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;

public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    private readonly IConfiguration _configuration;

    public BasicAuthenticationHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock,
        IConfiguration configuration)
        : base(options, logger, encoder, clock)
    {
        _configuration = configuration;
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.ContainsKey("Authorization"))
            return AuthenticateResult.Fail("Missing Authorization Header");

        string username = null;
        string password = null;
        try
        {
            var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]);
            var credentialBytes = Convert.FromBase64String(authHeader.Parameter);
            var credentials = Encoding.UTF8.GetString(credentialBytes).Split(':');
            username = credentials[0];
            password = credentials[1];
        }
        catch
        {
            return AuthenticateResult.Fail("Invalid Authorization Header");
        }

        var appSettings = _configuration.GetSection("BasicAuthSettings");
        var appUsername = appSettings["Username"];
        var appPassword = appSettings["Password"];

        if (username != appUsername || password != appPassword)
            return AuthenticateResult.Fail("Invalid Username or Password");

        var claims = new[] {
            new Claim(ClaimTypes.NameIdentifier, username),
            new Claim(ClaimTypes.Name, username),
        };
        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        return AuthenticateResult.Success(ticket);
    }
}
C#
BasicAuthMiddleware.cs
using System.Net.Http.Headers;
using System.Text;

public class BasicAuthMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IConfiguration _configuration;

    public BasicAuthMiddleware(RequestDelegate next, IConfiguration configuration)
    {
        _next = next;
        _configuration = configuration;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (!context.Request.Headers.ContainsKey("Authorization"))
        {
            context.Response.StatusCode = 401;
            return;
        }

        var authHeader = AuthenticationHeaderValue.Parse(context.Request.Headers["Authorization"]);
        var credentialBytes = Convert.FromBase64String(authHeader.Parameter);
        var credentials = Encoding.UTF8.GetString(credentialBytes).Split(':');
        var username = credentials[0];
        var password = credentials[1];

        var appSettings = _configuration.GetSection("BasicAuthSettings");
        var appUsername = appSettings["Username"];
        var appPassword = appSettings["Password"];

        if (username != appUsername || password != appPassword)
        {
            context.Response.StatusCode = 401;
            return;
        }

        await _next(context);
    }

}
C#
S3Client.cs
using Amazon.S3;
using Amazon.S3.Transfer;


public class S3Client
{
    private readonly IAmazonS3 _client;
    private readonly string _bucketName = "Policies";
    private readonly ILogger<S3Client> _logger;
    private readonly double _expirationMinutes;

    public S3Client(IConfiguration configuration, ILogger<S3Client> logger)
    {
        var awsOptions = configuration.GetAWSOptions();
        _logger = logger;
        _client = awsOptions.CreateServiceClient<IAmazonS3>();
        _bucketName = configuration.GetValue<string>("BucketName") ?? "Policies";
        _expirationMinutes = configuration.GetValue("ExpirationMinutes", 10);
    }

    public async Task<string> UploadFileAsync(string fileName)
    {
        var fileTransferUtility = new TransferUtility(_client);
        await fileTransferUtility.UploadAsync(fileName, _bucketName);
        _logger.LogInformation("File uploaded to S3");

        return GetValetUrl(fileName, _expirationMinutes);
    }

    public async Task<string> UploadFileAsync(Stream fileStream, string fileName)
    {
        var fileTransferUtility = new TransferUtility(_client);
        await fileTransferUtility.UploadAsync(fileStream, _bucketName, fileName);
        _logger.LogInformation("File uploaded to S3");
        
        return GetValetUrl(fileName, _expirationMinutes);
    }

    public async Task<Stream> DownloadFileAsync(string fileName)
    {
        var fileUrl = $"https://{_bucketName}.s3.amazonaws.com/{fileName}";
        var request = new Amazon.S3.Model.GetObjectRequest
        {
            BucketName = _bucketName,
            Key = fileName
        };

        using var response = await _client.GetObjectAsync(request);
        var stream = new MemoryStream();
        await response.ResponseStream.CopyToAsync(stream);
        stream.Position = 0;

        return stream;
    }
    
    public string GetValetUrl(string fileName, double? expirationMinutes)
    {
        expirationMinutes ??= _expirationMinutes;
        var fileUrl = $"https://{_bucketName}.s3.amazonaws.com/{fileName}";
        _logger.LogDebug($"File URL: {fileUrl}");
        var request = new Amazon.S3.Model.GetPreSignedUrlRequest
        {
            BucketName = _bucketName,
            Key = fileName,
            Expires = DateTime.UtcNow.AddMinutes(expirationMinutes.Value)
        };

        var url = _client.GetPreSignedURL(request);

        return url;
    }
}
C#
appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "AWS": {
    "Region": "<region>",
    "AccessKey": "<your-access-key>",
    "SecretAccessKey": "<your-secret-access-key>"
  },
  "BucketName": "<bucket-name>",
  "BasicAuthSettings": {
    "Username": "<username>",
    "Password": "<password>"
  },
  "ExpirationMinutes": 10,
  "CannedResponses": true
}
JSON

Traefik configuration

YAML
version: '3.7'

services:
  traefik:
    image: traefik:v2.8
    command:
      - "--configFile=/traefik.yml"
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - /home/ec2-user/traefik/config/traefik.yml:/traefik.yml:ro
      - /home/ec2-user/traefik/config/dynamic:/dynamic:ro
      - /home/ec2-user/traefik/config/certs:/certs:rw
    network_mode: host
YAML
dynamic_conf.yml
http:
  routers:
    blob-storage:
      rule: "Host(`<FQDN>`)"
      service: blob-storage
      entrypoints:
        - web
        - websecure
      tls:
        certResolver: resolver
      middlewares:
        - ipwhitelist

  services:
    blob-storage:
      loadBalancer:
        servers:
          - url: "http://localhost:5001"

  serversTransports:
    default:
      insecureSkipVerify: true

  middlewares:
    ipwhitelist:
      ipWhiteList:
        sourceRange:
          - "0.0.0.0"
YAML
YAML
entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

providers:
  file:
    directory: "/dynamic"
    watch: true

api:
  insecure: true
  dashboard: true

certificatesResolvers:
  resolver:
    acme:
      email: "<email address>"
      storage: "certs/acme.json"
      httpChallenge:
        # used during the challenge
        entryPoint: web

tls:
  options:
    default:
      minVersion: VersionTLS11
YAML