fix(api): answer 401 for a missing or wrong credential, not 403 - #1332
Merged
Conversation
Route has three auth arms and they all mean the same thing when they fail: the credential was missing or wrong. RFC 7235 spells that 401. 403 means "I know who you are and you may not", which none of them can have established -- all three run before any principal is bound. _testToken() was the one arm answering 403, and it runs FIRST, so it decided the response for every unauthenticated request. An API call with no headers at all came back 403 with an empty body while a merely wrong Bearer token came back 401, so anyone debugging a client from the status code was sent looking for a permission they did not have rather than for the credential they had not sent. Second half, same method: it recorded an audit row unconditionally, so "nobody presented anything" was written down as a rejected credential. _testAuth() already had the rule one method below -- an empty header is not an attempt -- and _testToken() did not. On the lab server that was 73 api-token rejection rows against 1 user-token row, which is not a difference in traffic. Rejections are worth reading because they are rare, and this buried them. No WWW-Authenticate header, which RFC 7235 would otherwise want on a 401. It is what makes a browser throw a native basic-auth prompt and the management UI reaches these routes over XHR, so that is a real regression traded against a header no FOG client reads. The omission is pinned so it is not "fixed" later by accident. AUTHENTICATION BEHAVIOR IS UNCHANGED -- only the status code and the audit row are. Verified on a live 1.6 install: server token + valid basic auth 200 server token + wrong basic auth 401 no server token + valid basic auth 401 (both were always required) no auth at all 401 (was 403) wrong fog-api-token 401 (was 403) empty fog-api-token 401 (was 403) bogus Bearer 401 (unchanged) system/info 200 (unauthenticated route) Those same four unauthenticated calls added ONE audit row between them -- the wrong-token attempt -- where previously each would have added its own. The bearer arm's auditing is untouched and still records. 13 checks in tests/api-unauthenticated-401.test.php, three mutation-verified: reverting to 403, auditing unconditionally again, and adding a WWW-Authenticate header. The no-403 assertion strips comments first, because these docblocks discuss 403 on purpose. Pinned as "no arm sends 403" rather than by naming the three, so a fourth auth mechanism cannot reintroduce the inconsistency by being written to a different rule. Co-Authored-By: Claude <noreply@anthropic.com>
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.
Found while probing the API token work.
Routehas three auth arms and theyall mean the same thing when they fail — the credential was missing or wrong.
RFC 7235 spells that 401. 403 means "I know who you are and you may
not", which none of them can have established: all three run before any
principal is bound.
_testToken()was the one arm answering 403, and it runs first, so itdecided the response for every unauthenticated request:
Anyone debugging a client from the status code was sent looking for a
permission they did not have, rather than for the credential they had not
sent.
Second half: the audit row
_testToken()recorded a rejection unconditionally, so "nobody presentedanything" was written down as a rejected credential.
_testAuth()already hasthe rule one method below — an empty header is not an attempt — and
_testToken()did not.On the lab server that is 73 api-token rejection rows against 1 user-token
row. That is not a difference in traffic; it is this method recording the
ordinary case of an unauthenticated request. Rejections are worth reading
because they are rare, and this buried them.
Authentication behavior is unchanged
Only the status code and the audit row change. Verified on a live 1.6 install:
fog-api-tokenfog-api-tokensystem/info(unauthenticated route)Both credentials were always required together — the third row is not a
regression, it is the pre-existing design, and the 200 in the first row is the
proof the change did not break the path.
Those four unauthenticated calls added one audit row between them, the
wrong-token attempt, where previously each would have added its own. The
bearer arm's auditing is untouched and still records.
No WWW-Authenticate
RFC 7235 would otherwise want one on a 401. It is also what makes a browser
throw a native basic-auth prompt, and the management UI reaches these same
routes over XHR — a real regression traded against a header no FOG client
reads. The omission is pinned so it does not get "fixed" later by accident.
Not changed, and why
hash_equals()against an emptyFOG_API_TOKENmatches an absent header, soon a server with that setting blank
_testToken()always passes. That isnot a bypass:
_testToken()and_testAuth()both have to pass, so a usercredential is still required. Left alone rather than hardened speculatively —
the sequencing is the real defense — but worth knowing if that sequencing is
ever refactored.
Tests
tests/api-unauthenticated-401.test.php, 13 checks, three mutation-verified:reverting to 403, auditing unconditionally again, and adding a
WWW-Authenticateheader. The no-403 assertion strips comments first, becausethese docblocks discuss 403 on purpose.
Pinned as "no arm sends 403" rather than by naming the three, so a fourth
auth mechanism cannot reintroduce the inconsistency by being written to a
different rule.
Downstream
Clients that branch on 403 vs 401 for these routes would see the change. FOG's
own client does not reach these routes; FogApi should be checked, but it
treats both as failure.