Homelab Weekly: Security Hardening, SQL Server Support, and Mobile Testing

Aug 10, 2026 min read

Another busy week across the homelab stack. The big themes this time around are security hardening in DurpDeploy (a batch of fixes that came through the GitHub-to-GitLab sync pipeline), a major new database backend, mobile browser testing infrastructure, and some overdue resource limit tuning in the Kubernetes cluster. I also finally pulled the plug on OpenClarity after a short and frustrating experiment. Here is the full rundown.

DurpDeploy: Security Hardening Wave

This was the week where a lot of the security backlog got cleared. Most of these came through the GitHub Issues sync pipeline, where Codex proposes fixes and I review and merge them into the GitLab mainline. The volume was high, but each fix addressed a real vulnerability class, so it was worth the attention.

SSRF Protection for Notification Webhooks

The notification system (Slack, Gotify, Discord) accepted arbitrary URLs from the admin settings forms and the API, then made HTTP POST requests to those URLs when deployment events fired. There was no validation at all. An attacker with admin access (or a compromised admin session) could point a webhook at http://169.254.169.254/latest/meta-data/ or any internal service on the host network and use the application as an SSRF proxy.

I added a validateNotificationURLs function in the notify package that runs before any URL gets persisted. It rejects private IP ranges (RFC 1918, link-local, loopback), blocks non-HTTP schemes, and resolves hostnames to verify the target is not a local address. The validation runs in both the HTML form handlers and the API handlers, so there is no path that bypasses it. I also refactored the notifier constructors (Slack, Gotify, Discord) to accept an injected *http.Client via NewXxxNotifierWithClient, which made testing the validation logic straightforward without standing up real endpoints.

The diff was 344 additions and 28 deletions across the handler layer and the notify package. Every notification entry point now validates before storage.

Secret Variable Disclosure in Views

The variable edit form was rendering the plaintext value of secret variables into the HTML input field. The template used variable.Value.String directly, which meant secrets were visible in the page source and in browser dev tools even though the input type was set to “password”. The password input type is a visual mask, not a security boundary.

I added a variableEditValue helper in the variables template that returns an empty string for secret variables, and updated the release detail view to render a redacted bullet placeholder instead of the actual value when v.Secret != 0. On the update path, if a secret variable is submitted with an empty value, the handler now preserves the existing stored value instead of overwriting it with an empty string. This means you can edit a secret variable’s name or environment scope without accidentally wiping the secret.

Admin-Only Force Deploy Bypass

The lifecycle gate system lets you define promotion rules (dev to staging to production, for example). When a deployment violates a gate, the user can pass a force parameter to override. The problem was that any authenticated user with deploy permission could force past lifecycle gates. That is not the right security boundary. Lifecycle gates exist because production deployments carry risk, and the override should require elevated privilege.

I added a role check in ScheduleDeployment: when force is true and a gate violation exists, the handler now verifies that the authenticated user has the admin role. Non-admin deployers get a 403 with a clear message. The test suite covers both the admin-allowed path and the deployer-denied path.

Cross-Project IDOR Fixes (Two Separate Issues)

Two IDOR vulnerabilities came through the sync pipeline. Both involved missing ownership checks on nested resources.

The first one (routes) was about nested objects (variables, releases, deployments) not verifying that the parent project in the URL path matched the actual owner of the resource. A user with access to project A could access project B’s resources by guessing sequential IDs. I added ownership enforcement to the route middleware so every nested resource lookup validates project membership before proceeding. That was 161 additions across the auth and handler layers.

The second one (save-as-template) was similar but specific to the step template feature. When saving a deployment step as a reusable template, the handler did not verify that the source step belonged to a project the user had access to. I added a project ownership check before the template creation logic. Smaller fix (65 additions) but the same class of bug.

Command Injection in Release Workflow

Release tags were being interpolated directly into shell commands without sanitization. A malicious tag like v1.0; rm -rf / would get executed by the deployment runner. I hardened the tag handling to validate the tag format (alphanumeric, dots, hyphens only) before it reaches any shell invocation. The fix was 14 additions and 6 deletions, focused on the release creation path.

Runner Sandbox: Clear Capabilities Before Step Execution

The deployment runner executes step scripts via os/exec with bash. On Linux, child processes inherit the parent’s capability set. If the server process is running with any elevated capabilities (which it should not be, but defense in depth matters), those capabilities would leak into every deployment step. I added a setpriv call before executing each step script that clears all capabilities and drops to the unprivileged user. This required adding util-linux to the Alpine base image in the Dockerfile and the CI pipeline. The diff was 67 additions and 2 deletions, with new tests verifying the capability state of child processes.

CI Credential and Lifecycle Script Hardening

Two small but important CI fixes. First, the GitHub Actions workflows (release and lint/test) were using actions/checkout without persist-credentials: false. By default, the checkout action leaves a git credential helper configured with the workflow token, which persists on disk for the rest of the job. Any subsequent step (including third-party actions) could use that token. I added persist-credentials: false to both checkout steps.

Second, the Makefile’s npm-install target was using npm install, which runs lifecycle scripts (preinstall, postinstall, etc.) from every dependency. A compromised transitive dependency could execute arbitrary code during the build. I switched to npm ci --ignore-scripts, which is both more deterministic (uses the lockfile exactly) and skips lifecycle scripts entirely.

Pinning CI Tooling Versions

The CI pipeline was installing templ and golines with @latest, which means every pipeline run could pull a different version. This is a supply chain risk (a compromised upstream release gets immediate access to the build) and a reproducibility problem (builds are not reproducible if the tool versions change between runs).

I pinned templ to v0.3.1020 across all three CI stages (lint, test, build), pinned golines to v0.12.2 in both the CI lint stage and the pre-commit hook script, and pinned the base Docker image to a specific digest (golang:1.26-alpine@sha256:0178a641fbb4...) instead of the floating tag. The pre-commit hook’s error message was also updated to reference the pinned version so developers install the same version CI expects.

Postgres Placeholder Rewrite Fix

The SQL rewrite layer that translates SQLite-style ? placeholders to PostgreSQL $N placeholders had a bug with sqlc’s numbered placeholder syntax. sqlc generates queries like SELECT ?1, ?1, ?2 where the number indicates which argument the placeholder refers to, and the same number can appear multiple times for the same argument. The rewriter was treating each ? independently and assigning sequential $1, $2, $3 numbers, which broke queries where ?1 appeared twice (both references need to become $1, not $1 and $2).

I updated rewritePlaceholders to detect the ?N pattern (a ? followed by digits), parse the number, and preserve it in the output. The counter tracks the maximum numbered placeholder seen so that unnumbered ? placeholders continue to get the next available number. I also added test cases for numbered placeholders inside string literals (which should be left untouched) and mixed numbered/unnumbered queries.

Streaming Log Exports

The log export endpoint was loading all deployment logs into memory as a slice, building the entire response body in a strings.Builder, then writing it to the HTTP response. For deployments with thousands of log lines, this meant unbounded memory allocation proportional to the log size. A single export of a verbose deployment could spike memory usage significantly.

I replaced the bulk query with a new ForEachDeploymentLogByDeploymentAsc method on the repository that streams rows one at a time using rows.Next(). The handler now sets the response headers (Content-Type and Content-Disposition) upfront, writes the header line, then iterates through the rows writing each line directly to the http.ResponseWriter. Memory usage is now constant regardless of log volume. The rows are ordered by created_at ASC, id ASC to maintain the correct chronological order.

Backup/Restore Test Listener Exposure

The backup/restore integration test script was binding the test server to localhost:8080 with a hardcoded password. On shared CI runners or developer machines, port 8080 might already be in use, and the hardcoded credentials in the test script were a bad pattern even for test-only code.

I changed the script to allocate a random available port using Python’s socket.bind(("127.0.0.1", 0)) pattern, and generate a random admin password with openssl rand -base64 32. The server binary also got a new DURPDEPLOY_ADDR environment variable (via a loadAddr function in main.go) so the test can tell it which address to bind. The default remains :8080 for normal operation.

DurpDeploy: SQL Server Support

This is the biggest feature of the week. DurpDeploy now supports Microsoft SQL Server as a database backend alongside SQLite and PostgreSQL. The diff was over 3,300 additions.

The implementation uses github.com/microsoft/go-mssqldb as the underlying driver, wrapped in a new internal/mssqldriver package that follows the same pattern as the existing PostgreSQL driver wrapper. The wrapper intercepts SQL queries and rewrites SQLite-isms into SQL Server equivalents, similar to how the PostgreSQL driver rewrites ? placeholders to $N.

The migration system (internal/migrate) got a new IsSQLServer function that detects the driver from the DSN prefix (sqlserver:// or SQLSERVER://), and the migration runner now handles all three backends. SQL Server has different DDL syntax (no AUTOINCREMENT, uses IDENTITY instead; no BOOLEAN, uses BIT; different date functions), so the migration files needed dialect-aware handling.

The test infrastructure uses testcontainers-go to spin up a real SQL Server container for integration tests. I added several test files covering schema parity (verifying that defaults, indexes, and constraints match the SQLite and PostgreSQL behavior), query rewrites (verifying the SQL translation layer produces correct results), pagination (SQL Server uses OFFSET/FETCH instead of LIMIT/OFFSET), and full deployment lifecycle parity (creating projects, environments, releases, and running deployments through the full flow on SQL Server).

The Makefile got new targets (dev-mssql, e2e-mssql) for local development against a SQL Server container. Documentation was updated to cover the SQL Server option alongside the existing SQLite and PostgreSQL guides.

DurpDeploy: Mobile Browser Testing

The second large feature this week was a mobile browser testing framework. The diff was nearly 6,000 additions.

The core idea is a Playwright-based test suite that runs the actual DurpDeploy web UI in a mobile browser viewport and verifies that the responsive layout works correctly. This is separate from the unit tests (which test handler logic) and the existing e2e tests (which test with curl against the HTTP API).

The test infrastructure includes a custom Docker image (Dockerfile.mobile-browser) based on Microsoft’s Playwright runtime with Go 1.26 installed on top, so the tests can build and run the server binary and then drive it with a real Chromium browser in mobile emulation mode.

The test files are gated behind a mobilebrowser build tag so they do not run during normal go test execution. They are only executed in the dedicated mobile:browser CI job, which uses a resource_group: mobile-browser to serialize execution (browser tests are resource-intensive and do not benefit from parallelism).

The tests cover navbar behavior (account controls in a right-aligned mobile menu), lifecycle reorder accessibility (labels remain unique even with duplicate environment names), deployment form rendering in mobile viewport, and cleanup of browser profiles and processes when tests are interrupted. There is also a “strictness” mode that enables additional geometry checks and a receipt system that creates evidence directories for test runs.

The CI job copies the repository into a created container (since Docker-in-Docker cannot bind-mount the checkout), runs the test binary, and copies the secret-free artifacts (screenshots, receipts) back out.

DurpDeploy: Dependency Updates

Two Renovate merge requests landed this week. The go-chi/chi/v5 router was bumped from its previous version to v5.3.1, and pressly/goose/v3 (the migration library) was updated to v3.27.2. Both are routine dependency updates that came through the automated pipeline.

Dependabot also contributed: a PostCSS bump from 8.5.15 to 8.5.25 (across the npm_and_yarn group), and a fix for the .github/dependabot.yml configuration where the package-ecosystem value was invalid.

GitOps: Resource Limits for ArgoCD and Authentik

The Talos cluster has been running ArgoCD and Authentik without resource limits, which meant both applications could consume unbounded memory. ArgoCD’s application controller in particular has a known tendency to spike memory usage during reconciliation of large application sets, and it was getting OOMKilled by the node’s resource pressure handler rather than being gracefully managed.

I added explicit resource requests and limits to both ArgoCD and Authentik in their Helm values files.

For ArgoCD (infra-talos/argocd/values.yaml):

  • Controller: 250m CPU request, 512Mi memory request, 2Gi memory limit
  • Server: 100m CPU request, 128Mi memory request, 512Mi memory limit
  • Repo Server: similar limits (the diff was truncated but followed the same pattern)

For Authentik (both infra-talos/authentik/values.yaml and infra/authentik/values.yaml since we run it in two clusters):

  • PostgreSQL: 250m CPU request, 512Mi memory request, 1000m CPU limit, 1Gi memory limit
  • Redis: 100m CPU request, 128Mi memory request, 250m CPU limit, 256Mi memory limit

These values are conservative starting points. The memory limits are set based on observed peak usage plus headroom, and I expect to tune them over the next few weeks as we collect real metrics. The important thing is that the applications now have defined boundaries instead of competing for node resources without constraints.

GitOps: OpenClarity Removal

OpenClarity was a vulnerability scanner I deployed to the cluster a couple of weeks ago. It did not work well on Talos. Talos is an immutable, API-driven Kubernetes distribution that does not have the traditional Linux runtime paths (no /var/run/containerd/containerd.sock accessible in the expected way, no traditional node shell access). OpenClarity’s cr-discovery-server needs to talk to the container runtime to discover workloads, and it expects to find the containerd socket at a standard path.

I first tried to fix it by manually adding volumes and volumeMounts for the containerd socket, working around a Helm chart bug where containerRuntimePaths was not being rendered as actual volumes. That fix (commit a94c8f72) added the socket mount but it was clear this was going to be an ongoing battle against the chart’s assumptions about the host environment.

After evaluating the effort versus the value (we have other vulnerability scanning options that work better with immutable distros), I removed OpenClarity entirely. The removal was 1,953 deletions across both the infra-talos and infra directory trees: the ArgoCD Application manifests, the Helm chart wrapper, the values files (900+ lines each), the ExternalSecret definitions for the PostgreSQL credentials, and the ServiceAccount. Clean removal with no residual configuration.

GitOps: Operational Updates

A few smaller operational changes in the gitops repository:

  • Vaultwarden was updated to a new version (a single-line image tag change in the values file).
  • The Unraid server endpoint was added to the internal proxy configuration (6 additions, defining the upstream target for the Unraid web UI).
  • DurpDeploy itself was added as an ArgoCD-managed application in the gitops repository (46 additions), defining the Application manifest so ArgoCD will deploy and sync DurpDeploy from its own repository. This completes the loop where DurpDeploy manages deployments for other services, and is itself deployed via GitOps.

Dear Marcus: Mobile Updates

The Dear Marcus project (a side application) had a burst of activity early in the week. The largest commit (3,611 additions, 1,019 deletions) was a broad update that touched most of the codebase. Subsequent commits refined the swipe interaction (122 additions, 56 deletions), updated test fixtures, and added screenshots to the README with documentation of the local storage policy.

The README update included two screenshots showing the mobile interface and clarified that user data is stored locally on the device rather than synced to a server. This is consistent with the app’s privacy-first design.

Looking Ahead

The SQL Server support is new and needs real-world validation. I have integration tests covering the major query patterns, but there will inevitably be edge cases in the SQL rewrite layer that only show up with complex application queries. I expect to spend some time next week running DurpDeploy against a SQL Server instance with realistic workloads and fixing any translation bugs that surface.

The mobile browser test suite is comprehensive but slow. The CI job takes several minutes because it builds the server, starts a real browser, and runs through multiple viewport configurations. If the suite grows, I will need to either parallelize it (splitting into multiple jobs by test category) or invest in faster browser startup. For now, the serialized execution with the resource group is acceptable.

The resource limits for ArgoCD and Authentik are starting points. I need to set up monitoring dashboards that track actual usage against these limits so I can right-size them based on data rather than guesses. The 2Gi memory limit for the ArgoCD controller is probably generous, but I would rather start high and reduce than start low and get paged for OOMKills.

The OpenClarity removal leaves a gap in vulnerability scanning for the Talos cluster. I need to evaluate alternatives that are designed for immutable Kubernetes distributions, or accept that the existing scanning from the CI pipeline (Dependabot, Renovate, container image scanning) provides sufficient coverage for a homelab environment.

The SSRF validation for notification webhooks is solid but could be extended. Currently it blocks private IP ranges at validation time, but DNS rebinding could theoretically bypass the check if the hostname resolves to a public IP at validation time and a private IP at delivery time. Adding a second check at delivery time (or using a DNS resolver that pins the resolution) would close that gap. It is a low-risk attack vector for this application (requires admin access to set the webhook in the first place), but it is worth addressing when I revisit the notification system.