Files
CaddyManager/CaddyManager.Services/Caddy/CaddyService.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

270 lines
9.0 KiB
C#

using CaddyManager.Contracts.Configurations.Caddy;
using CaddyManager.Contracts.Caddy;
using CaddyManager.Contracts.Configurations;
using CaddyManager.Contracts.Models.Caddy;
namespace CaddyManager.Services.Caddy;
/// <inheritdoc />
public class CaddyService(
IConfigurationsService configurationsService,
ICaddyConfigurationParsingService parsingService) : ICaddyService
{
/// <summary>
/// File name of the global configuration Caddyfile
/// </summary>
private const string CaddyGlobalConfigName = "Caddyfile";
private CaddyServiceConfigurations Configurations => configurationsService.Get<CaddyServiceConfigurations>();
/// <inheritdoc />
public List<CaddyConfigurationInfo> GetExistingCaddyConfigurations()
{
if (!Directory.Exists(Configurations.ConfigDir))
{
Directory.CreateDirectory(Configurations.ConfigDir);
}
return [.. Directory.GetFiles(Configurations.ConfigDir)
.Where(filePath => Path.GetFileName(filePath) != CaddyGlobalConfigName)
.Select(filePath =>
{
var fileName = Path.GetFileNameWithoutExtension(filePath);
var info = GetCaddyConfigurationInfo(fileName);
info.FileName = fileName;
return info;
})
.OrderBy(info => info.FileName)];
}
/// <inheritdoc />
public string GetCaddyConfigurationContent(string configurationName)
{
// Reading went unguarded while saving, renaming and deleting all validated the name, so a
// caller could walk out of the configuration directory and read any *.caddy file on disk
if (configurationName != CaddyGlobalConfigName && IsInvalidFileName(configurationName))
{
return string.Empty;
}
var path = configurationName == CaddyGlobalConfigName
? Path.Combine(Configurations.ConfigDir, CaddyGlobalConfigName)
: Path.Combine(Configurations.ConfigDir, $"{configurationName}.caddy");
if (File.Exists(path))
{
return File.ReadAllText(path);
}
return string.Empty;
}
/// <inheritdoc />
public string GetCaddyGlobalConfigurationContent() => GetCaddyConfigurationContent(CaddyGlobalConfigName);
/// <inheritdoc />
public CaddyOperationResponse SaveCaddyConfiguration(CaddySaveConfigurationRequest request)
{
if (string.IsNullOrWhiteSpace(request.FileName))
{
return new CaddyOperationResponse
{
Success = false,
Message = "The configuration file name is required"
};
}
if (request.FileName != CaddyGlobalConfigName && IsInvalidFileName(request.FileName))
{
return Failure("The configuration file name contains invalid characters");
}
var filePath = Path.Combine(Configurations.ConfigDir,
request.FileName == CaddyGlobalConfigName ? CaddyGlobalConfigName : $"{request.FileName}.caddy");
// if in the new mode, we would have to check if the file already exists
if (request.IsNew && File.Exists(filePath))
{
return new CaddyOperationResponse
{
Success = false,
Message = "The configuration file already exists"
};
}
try
{
File.WriteAllText(filePath, request.Content);
return new CaddyOperationResponse
{
Success = true,
Message = "Configuration file saved successfully"
};
}
catch (Exception e)
{
return new CaddyOperationResponse
{
Success = false,
Message = e.Message
};
}
}
/// <inheritdoc />
public CaddyOperationResponse SaveCaddyGlobalConfiguration(string content) => SaveCaddyConfiguration(
new CaddySaveConfigurationRequest
{
FileName = CaddyGlobalConfigName,
Content = content
});
/// <inheritdoc />
public CaddyOperationResponse RenameCaddyConfiguration(string oldFileName, string newFileName)
{
if (string.IsNullOrWhiteSpace(newFileName))
{
return Failure("The configuration file name is required");
}
if (oldFileName == CaddyGlobalConfigName || newFileName == CaddyGlobalConfigName)
{
return Failure("The global Caddyfile cannot be renamed");
}
if (oldFileName == newFileName)
{
return new CaddyOperationResponse
{
Success = true,
Message = "Configuration file renamed successfully"
};
}
if (IsInvalidFileName(newFileName))
{
return Failure("The configuration file name contains invalid characters");
}
var oldPath = Path.Combine(Configurations.ConfigDir, $"{oldFileName}.caddy");
var newPath = Path.Combine(Configurations.ConfigDir, $"{newFileName}.caddy");
if (!File.Exists(oldPath))
{
return Failure("The configuration file to rename does not exist");
}
// On case insensitive file systems the target resolves to the source for a case only rename,
// so the collision check has to be skipped there
var caseOnlyRename = string.Equals(oldFileName, newFileName, StringComparison.OrdinalIgnoreCase);
if (!caseOnlyRename && File.Exists(newPath))
{
return Failure("The configuration file already exists");
}
try
{
if (caseOnlyRename)
{
// A direct move would be rejected as an existing destination on a case insensitive
// file system, so go through an intermediate name that cannot collide
var tempPath = Path.Combine(Configurations.ConfigDir, $"{newFileName}.caddy.renaming");
File.Move(oldPath, tempPath);
File.Move(tempPath, newPath);
}
else
{
File.Move(oldPath, newPath);
}
return new CaddyOperationResponse
{
Success = true,
Message = "Configuration file renamed successfully"
};
}
catch (Exception e)
{
return Failure(e.Message);
}
}
private static CaddyOperationResponse Failure(string message) => new()
{
Success = false,
Message = message
};
/// <summary>
/// Guards the rename against path traversal and characters the file system would reject
/// </summary>
private static bool IsInvalidFileName(string fileName) =>
fileName.Contains("..") || fileName.IndexOfAny(InvalidFileNameChars) >= 0;
private static readonly char[] InvalidFileNameChars =
[.. Path.GetInvalidFileNameChars().Union(['/', '\\'])];
/// <inheritdoc />
public CaddyDeleteOperationResponse DeleteCaddyConfigurations(List<string> configurationNames)
{
var failed = new List<string>();
foreach (var configurationName in configurationNames)
{
if (configurationName != CaddyGlobalConfigName && IsInvalidFileName(configurationName))
{
failed.Add(configurationName);
continue;
}
var filePath = Path.Combine(Configurations.ConfigDir,
configurationName == CaddyGlobalConfigName ? CaddyGlobalConfigName : $"{configurationName}.caddy");
if (File.Exists(filePath))
{
try
{
File.Delete(filePath);
}
catch
{
failed.Add(configurationName);
}
}
else
{
failed.Add(configurationName);
}
}
return new CaddyDeleteOperationResponse
{
Success = failed.Count == 0,
Message = failed.Count == 0
? "Configuration(s) deleted successfully"
: $"Failed to delete the following configuration(s): {string.Join(", ", failed)}",
DeletedConfigurations = configurationNames.Except(failed).ToList()
};
}
/// <inheritdoc />
public CaddyConfigurationInfo GetCaddyConfigurationInfo(string configurationName)
{
var result = new CaddyConfigurationInfo
{
FileName = configurationName
};
var content = GetCaddyConfigurationContent(configurationName);
if (string.IsNullOrWhiteSpace(content))
{
return result;
}
result.Hostnames = parsingService.GetHostnamesFromCaddyfileContent(content);
result.ReverseProxyHostname = parsingService.GetReverseProxyTargetFromCaddyfileContent(content);
result.ReverseProxyPorts = parsingService.GetReverseProxyPortsFromCaddyfileContent(content);
result.Tags = parsingService.GetTagsFromCaddyfileContent(content);
return result;
}
}