feat(service): password-protect a service with --basic-auth - #46
Merged
Conversation
## Summary Adds `kamal-proxy deploy --basic-auth <username>:<password>`. Unauthenticated requests get a 401 with `WWW-Authenticate`; correct credentials pass through. Two things about the shape are deliberate and worth reading before reviewing. **It is not a middleware, despite what the issue says.** `createMiddleware` wraps *around* `serviceRequestWithTarget`, and the HTTPS redirect lives inside that handler (`handleRedirectsIfNeeded`). So every middleware slot runs before the 301, and a challenge issued from there makes the browser send the password in cleartext before it ever reaches https. The check therefore sits inline, after the redirect and before the pause check — after, so credentials are never solicited over plaintext; before, so protection does not lapse while a service is paused. Upstream PR basecamp#216 gets this wrong; it is also CONFLICTING and untriaged, so this is a rewrite rather than a port. **The CLI hashes the credential.** `EncodeBasicAuthCredential` produces `sha256:<hex salt>:<hex digest>`, so no plaintext crosses the RPC socket or reaches the state file. Salted per encode, so one leaked state file cannot confirm that two services share a password. Deliberately not an adaptive hash: verification runs on the unauthenticated request path, where a slow one is an uncacheable CPU-exhaustion primitive handed to exactly the people the password excludes. Comparison is `subtle.ConstantTimeCompare` over two fixed-length digests, so neither field leaks its length. Also: `Authorization` is stripped before the exemptions, so a browser replaying cached credentials to the health check path cannot forward them upstream; a health check path of `/` is rejected when auth is on, since that path is served without credentials; an unreadable stored credential denies that one service rather than aborting the decode of the whole state file; and the state file drops from 0644 to 0600, as it now carries a credential digest. ## Test Coverage - TestBasicAuth_RedirectsBeforeChallenging: a plaintext request to a TLS service is redirected, never challenged. The core security property - TestBasicAuth_ChallengesUnauthenticatedRequests / _AllowsCorrectCredentials / _RejectsWrongCredentials: the 401/200/401 triple from the issue - TestBasicAuth_ChallengeSurvivesInterceptErrors: --intercept-errors=401 does not strip WWW-Authenticate - TestBasicAuth_StripsAuthorizationBeforeForwarding / _OnExemptPaths - TestBasicAuth_ExemptsHealthCheckRequests / _RejectsRootHealthCheckPath - TestBasicAuth_UnreadableStoredCredentialFailsClosed - TestBasicAuth_StateWrittenBeforeTheOptionStaysUnprotected / _SurvivesStateRoundTrip - TestRouter_StateFileIsNotWorldReadable - TestParseBasicAuthFlag: cuts at the first colon only Four mutations were applied and confirmed to fail their tests: auth above the redirect, no Authorization strip, matches() always true, and fail-open on an unreadable credential. ## Verification - [x] gofmt -l internal/ cmd/ clean - [x] go vet ./... clean - [x] make test passes - [x] go test -race ./internal/server/ ./internal/cmd/ — 660 tests, clean Closes #8
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
kamal-proxy deploy service1 --target web-1:3000 --tls --host app.example.com --basic-auth admin:s3cr3tUnauthenticated requests get
401+WWW-Authenticate; correct credentials pass through. Closes #8.Two decisions differ from what the issue specifies, both deliberate.
1. It is not a middleware
The issue says "middleware in
createMiddleware(service.go:458)". Verified why that cannot be right:createMiddlewarewraps aroundserviceRequestWithTarget(service.go:382dispatches intos.middleware), and the HTTPS redirect lives inside that innermost handler —handleRedirectsIfNeededatservice.go:682. So every middleware slot runs before the 301-to-HTTPS. A challenge issued from up there makes the browser sendAuthorization: Basic <base64>in the clear before the redirect ever fires.The check therefore sits inline in
serviceRequestWithTarget:handleRedirectsIfNeeded— so credentials are never solicited over plaintext on a redirecting service;handlePausedAndStoppedRequests— so protection does not lapse while a service is paused.Upstream basecamp#216 challenges before the redirect. It is also
mergeable: CONFLICTING, from a first-time contributor, with no maintainer comment — so this is a rewrite against currentdash, not a cherry-pick.2. The CLI hashes the credential
EncodeBasicAuthCredentialemitssha256:<hex salt>:<hex digest>, so no plaintext crosses the RPC socket or reaches the state file. Salted per encode, so one leaked state file cannot confirm two services share a password.Deliberately not bcrypt. Verification runs on the unauthenticated request path; a ~60ms adaptive hash there is an uncacheable CPU-exhaustion primitive handed to exactly the people the password exists to exclude. Comparison is
subtle.ConstantTimeCompareover two fixed-length digests, so neither username nor password leaks its length. Thesha256:prefix keeps an upgrade path open. Honest limitation: this is not an adaptive hash, so a weak password in a readable state file is still open to offline dictionary attack.Also in this PR
Authorizationstripped above the exemptions--log-request-header authorization/rejected when auth is on--path-prefixalso resolves to/, so this covers bothdecodeStateServicesdoes onejson.Unmarshalover the whole slice andUnmarshalJSONcallsinitialize— returning an error there boots the proxy with zero services, and the.bakfails identically0644→0600, plus an explicitChmodinwriteFileAtomic0600Gem contract (zoolutions/kamal#12)
The gem emits one flag:
--basic-auth <username>:<password>. Split is at the first colon — passwords may contain colons, usernames may not, matchingnet/http's own decoder. Do not add it to rollout options:SetRolloutTargetsreuses the storedservice.options, so rollout traffic already inherits the credential.Release ordering matters here more than usual. gob drops unknown fields silently, so a new CLI against an old proxy deploys the service completely unauthenticated with no error. Publish the proxy image first, then bump
MINIMUM_VERSION.Test plan
make test,go vet ./...,gofmt -l internal/ cmd/cleango test -race ./internal/server/ ./internal/cmd/— 660 tests, cleanmake build+kamal-proxy deploy --helpshows the flagTestBasicAuth_ChallengesUnauthenticatedRequests,_AllowsCorrectCredentials,_RejectsWrongCredentialsTestBasicAuth_RedirectsBeforeChallenging— the core security propertyTestBasicAuth_ChallengeSurvivesInterceptErrors—--intercept-errors=401does not strip the challenge headerTestBasicAuth_StateWrittenBeforeTheOptionStaysUnprotected— old state files load open, never locked outTestRouter_StateFileIsNotWorldReadableFour mutations were applied to the implementation and confirmed to fail their tests, so these prove behavior rather than plumbing:
TestBasicAuth_RedirectsBeforeChallenging— "Should be empty, but was Basic realm=…"r.Header.Del("Authorization")removed_StripsAuthorizationBeforeForwarding,_OnExemptPathsmatches()always trueTestBasicAuthCredential_EncodeAndMatch(4 cases),_RejectsWrongCredentials(4 cases)_UnreadableStoredCredentialFailsClosedKnown gaps, documented not fixed
ps. Closing it needs both repos to adopt stdin — cross-repo, not size:S.--tls-redirect=falseand non-TLS services are challenged over plaintext. Warned at deploy, not rejected — the fork's own gem deletes--tlsfromdeploy_optionswhen load-balancing, so a hard error would break every load-balanced deployment that enables auth.serviceFormatches rawr.URL.Pathwith no normalization, so//adminand/./adminfall through to a sibling service. Documented in the README.s.basicAuthinserviceRequestWithTarget, same ass.options/s.middlewaretoday. Pre-existing pattern; credential rotation under traffic is not atomic. Worth its own issue.Deviations & judgment calls
Design came from a multi-agent pass and three adversarial reviews; the reviews found seven real problems, all folded in before any code was written.
--tls --tls-redirect=falsechallenges in cleartext, silently.redirectURLIfNeededonly upgrades whenTLSEnabled && TLSRedirect, so the "structurally closed" claim held only for redirecting services. Warning widened to!TLSEnabled || !TLSRedirect. I rejected the reviewer's second half — suppressingWWW-Authenticateon that path — becausessl_redirect: falseis exactly the TLS-terminated-upstream topology, where suppressing would break basic auth outright for those users. Warn and document beats silently half-working.Authorizationstrip was below the exemption early-return. Hoisted above both./would have exposed the protected index. Nothing validates that path; now rejected at deploy when auth is on.testRouter/sendGETRequestdriverouter.ServeHTTPwith noErrorPageMiddleware, soSetErrorResponsetakes thehttp.Errorfallback. Added a sharedtestRoutedHandlerhelper that wraps the router the wayServer.buildHandlerdoes.1, becauseDeployServicehealth-checks the backend during deploy. Noted because the same effect would flatter a naive version of that assertion into passing for the wrong reason.BasicAuthCredentialMatchestest helper rather than add production surface for tests. The CLI's real responsibility — cutting at the first colon — is extracted asparseBasicAuthFlagand table-tested; encoding is covered server-side.TestBasicAuth_DoesNotBlockACMEChallenges— not constructible as specified (no seam to inject aCertManager; forcingTLSEnabledmakes the control request a 301, not the asserted 401).TestBasicAuth_ExemptsInternalRequestscovers the sameisInternalRequestbranch the ACME and TLS on-demand probes travel through.BasicAuthUsername+BasicAuthPasswordHash. Makes the half-configured state (username set, hash empty ⇒ permanent 401) unrepresentable, keeps the username off disk, and means one digest and therefore one compare — no username timing oracle to write.--path-prefixalready deploys a distinctServicewith its ownServiceOptionsand therefore its own credential; a rule list would have fail-open polarity.internal/pages/401.html. It would be the first fork-only asset in an upstream-owned directory, and it does not help the--error-pages-without-401.html case anyway, since the service-level middleware only parses the operator's directory. README says to add one.router.goandutil.go, both upstream-owned, so it widens merge surface — three tokens plus aChmod, accepted because this change is what puts a credential digest in that file.