Files
CaddyManager/CaddyManager/Program.cs
T
eboloandClaude Opus 5 c484014752
Caddy Manager CI build / docker (push) Failing after 1m2s
Harden the read path, forwarded headers and security docs
Found while investigating an unrelated Gitea compromise: CaddyManager itself
was not involved, but reviewing it turned up three things worth closing.

Reading a configuration was the only file operation that did not validate the
name. Saving, renaming and deleting all reject `..`, `/` and `\`, so the read
path was the one way to leave the configuration directory and pull in any
`*.caddy` file on the host. The HTTP API happened to be covered, because GET
checks the name against the directory listing first, but the UI calls the
service directly and nothing stopped it.

Forwarded headers were trusted from any peer. That is correct only while the
container port is unreachable except through the proxy; the moment it is
published, a caller dictates the scheme, host and client address the app
believes in. Loopback and private space cover a proxy on a Docker network or on
the host, which is the documented deployment, and ignore everyone else.

The README never said that the `X-Api-Key` check guards `/api/*` and nothing
else, so the UI - which rewrites Caddyfiles and holds the Docker socket - reads
as protected when it is not. It now says so, and warns about the specific shape
that bit us: a second hostname added for machine callers whose only extra
directive is a `tls` line, which serves the unauthenticated UI to anyone who
can resolve it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 17:05:52 +07:00

132 lines
4.7 KiB
C#

using System.Net;
using CaddyManager.Api;
using CaddyManager.Components;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.OpenApi;
using MudBlazor.Services;
using NetCore.AutoRegisterDi;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services
.AddRazorComponents()
.AddInteractiveServerComponents();
// Auto register all the Services, Repositories that we have had within the code base
builder.Services.RegisterAssemblyPublicNonGenericClasses(System.Reflection.Assembly.GetAssembly(typeof(CaddyManager.Services.Caddy.CaddyService)))
.Where(t => t.Name.EndsWith("Service"))
.AsPublicImplementedInterfaces();
builder.Services.AddSignalR(e =>
{
// Caddyfiles are kilobytes; this is generous headroom for Monaco editor round-trips
e.MaximumReceiveMessageSize = 512 * 1024;
e.StreamBufferCapacity = 5;
});
// Keep as little per-circuit state resident as practical for a single-user admin UI
builder.Services.Configure<CircuitOptions>(o =>
{
o.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(1);
o.DisconnectedCircuitMaxRetained = 5;
o.MaxBufferedUnacknowledgedRenderBatches = 3;
});
builder.Services.AddOpenApi(options =>
{
// Declare the shared key scheme so the docs page offers an auth box and marks every endpoint as secured
options.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes[CaddyApi.ApiKeyHeader] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
In = ParameterLocation.Header,
Name = CaddyApi.ApiKeyHeader,
Description = "Shared key configured through Api:Key (environment variable Api__Key)",
};
document.Security ??= [];
document.Security.Add(new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference(CaddyApi.ApiKeyHeader, document)] = [],
});
return Task.CompletedTask;
});
});
// Caddy terminates TLS and forwards plain HTTP, so without this the app thinks every request is
// http and the OpenAPI document advertises http:// server URLs, which the browser blocks as mixed
// content when the docs page itself was served over https
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto |
ForwardedHeaders.XForwardedHost;
// The proxy is another container on a Docker network, so its address is not known up front.
// Trusting *any* peer's X-Forwarded-* was too broad: if the port is ever published beyond the
// proxy, a caller can dictate the scheme, host and client IP the app believes in. Docker
// networks and loopback are all private, so trusting only private space keeps the intended
// deployment working while ignoring headers from anywhere else.
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
foreach (var network in PrivateProxyNetworks)
{
options.KnownIPNetworks.Add(network);
}
});
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.VisibleStateDuration = 4000;
config.SnackbarConfiguration.HideTransitionDuration = 100;
config.SnackbarConfiguration.ShowTransitionDuration = 100;
});
var app = builder.Build();
// Has to run before anything that reads the scheme, host or client address
app.UseForwardedHeaders();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapCaddyApi();
app.MapOpenApi();
app.MapScalarApiReference();
app.Run();
/// <summary>
/// Address space a reverse proxy in front of this app can legitimately come from: loopback, the
/// RFC 1918 ranges Docker hands out, RFC 4193 unique local addresses and IPv6 loopback
/// </summary>
public partial class Program
{
internal static readonly System.Net.IPNetwork[] PrivateProxyNetworks =
[
new(IPAddress.Parse("127.0.0.0"), 8),
new(IPAddress.Parse("10.0.0.0"), 8),
new(IPAddress.Parse("172.16.0.0"), 12),
new(IPAddress.Parse("192.168.0.0"), 16),
new(IPAddress.Parse("::1"), 128),
new(IPAddress.Parse("fc00::"), 7),
];
}