Protect maintenance job endpoints. closes PrivateCaptcha/issues#178

This commit is contained in:
Taras Kushnir
2025-08-25 11:22:43 +03:00
parent be6ad9932d
commit 803b07d7f7
5 changed files with 43 additions and 5 deletions
+3 -2
View File
@@ -217,6 +217,7 @@ func run(ctx context.Context, cfg common.ConfigStore, stderr io.Writer, listener
CheckInterval: cfg.Get(common.HealthCheckIntervalKey),
Metrics: metrics,
}
jobs := maintenance.NewJobs(businessDB)
updateConfigFunc := func(ctx context.Context) {
cfg.Update(ctx)
@@ -225,6 +226,7 @@ func run(ctx context.Context, cfg common.ConfigStore, stderr io.Writer, listener
businessDB.UpdateConfig(maintenanceMode)
timeSeriesDB.UpdateConfig(maintenanceMode)
portalServer.UpdateConfig(ctx, cfg)
jobs.UpdateConfig(cfg)
verboseLogs := config.AsBool(cfg.Get(common.VerboseKey))
common.SetLogLevel(logLevel, verboseLogs)
}
@@ -308,7 +310,6 @@ func run(ctx context.Context, cfg common.ConfigStore, stderr io.Writer, listener
}()
// start maintenance jobs
jobs := maintenance.NewJobs(businessDB)
jobs.Add(healthCheck)
jobs.Add(&maintenance.SessionsCleanupJob{
Session: portalServer.Sessions,
@@ -372,7 +373,7 @@ func run(ctx context.Context, cfg common.ConfigStore, stderr io.Writer, listener
if localAddress := cfg.Get(common.LocalAddressKey).Value(); len(localAddress) > 0 {
localRouter := http.NewServeMux()
metrics.Setup(localRouter)
jobs.Setup(localRouter)
jobs.Setup(localRouter, cfg)
localRouter.Handle(http.MethodGet+" /"+common.LiveEndpoint, common.Recovered(http.HandlerFunc(healthCheck.LiveHandler)))
localRouter.Handle(http.MethodGet+" /"+common.ReadyEndpoint, common.Recovered(http.HandlerFunc(healthCheck.ReadyHandler)))
localServer = &http.Server{
+1
View File
@@ -1,5 +1,6 @@
STAGE=dev
PC_LOCAL_ADDRESS=localhost:9090
PC_LOCAL_API_KEY=BkdIDPmLPg6MDRbrr1TDLKMkhy
PC_PORTAL_BASE_URL=portal.privatecaptcha.local
PC_API_BASE_URL=api.privatecaptcha.local
PC_CDN_BASE_URL=cdn.privatecaptcha.local
+1
View File
@@ -39,6 +39,7 @@ const (
UserFingerprintIVKey
APISaltKey
EnterpriseLicenseKeyKey
LocalAPIKeyKey
// Add new fields _above_
COMMON_CONFIG_KEYS_COUNT
)
+1
View File
@@ -60,6 +60,7 @@ func init() {
configKeyToEnvName[common.EmailFromKey] = "PC_EMAIL_FROM"
configKeyToEnvName[common.ReplyToEmailKey] = "PC_REPLY_TO_EMAIL"
configKeyToEnvName[common.LocalAddressKey] = "PC_LOCAL_ADDRESS"
configKeyToEnvName[common.LocalAPIKeyKey] = "PC_LOCAL_API_KEY"
configKeyToEnvName[common.MaintenanceModeKey] = "PC_MAINTENANCE_MODE"
configKeyToEnvName[common.RegistrationAllowedKey] = "PC_REGISTRATION_ALLOWED"
configKeyToEnvName[common.HealthCheckIntervalKey] = "PC_HEALTHCHECK_INTERVAL"
+37 -3
View File
@@ -28,6 +28,7 @@ type jobs struct {
oneOffJobs []common.OneOffJob
maintenanceCancel context.CancelFunc
maintenanceCtx context.Context
apiKey string
mux sync.Mutex
}
@@ -71,10 +72,43 @@ func (j *jobs) Run() {
}
}
func (j *jobs) Setup(mux *http.ServeMux) {
func (j *jobs) UpdateConfig(cfg common.ConfigStore) {
j.apiKey = cfg.Get(common.LocalAPIKeyKey).Value()
}
func (j *jobs) Setup(mux *http.ServeMux, cfg common.ConfigStore) {
j.apiKey = cfg.Get(common.LocalAPIKeyKey).Value()
const maxBytes = 256 * 1024
mux.Handle(http.MethodPost+" /maintenance/periodic/{job}", common.Recovered(http.MaxBytesHandler(http.HandlerFunc(j.handlePeriodicJob), maxBytes)))
mux.Handle(http.MethodPost+" /maintenance/oneoff/{job}", common.Recovered(http.MaxBytesHandler(http.HandlerFunc(j.handleOneoffJob), maxBytes)))
mux.Handle(http.MethodPost+" /maintenance/periodic/{job}", common.Recovered(http.MaxBytesHandler(j.security(http.HandlerFunc(j.handlePeriodicJob)), maxBytes)))
mux.Handle(http.MethodPost+" /maintenance/oneoff/{job}", common.Recovered(http.MaxBytesHandler(j.security(http.HandlerFunc(j.handleOneoffJob)), maxBytes)))
}
func (j *jobs) security(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if len(j.apiKey) == 0 {
slog.WarnContext(ctx, "Endpoint is not allowed without a configured API key")
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
secret := r.Header.Get(common.HeaderAPIKey)
if len(secret) == 0 {
slog.WarnContext(ctx, "Request API key is empty")
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
if secret != j.apiKey {
slog.WarnContext(ctx, "Request API key does not match", "value", secret)
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func (j *jobs) handlePeriodicJob(w http.ResponseWriter, r *http.Request) {