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
The minimal APIs of the initial prototype are below:
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.
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#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#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#{
"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
}
JSONTraefik configuration
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
YAMLhttp:
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"
YAMLentryPoints:
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