Skip to content

fix(api): answer 401 for a missing or wrong credential, not 403 - #1332

Merged
mastacontrola merged 1 commit into
working-1.6from
fix-api-no-credential-401
Aug 23, 2026
Merged

fix(api): answer 401 for a missing or wrong credential, not 403#1332
mastacontrola merged 1 commit into
working-1.6from
fix-api-no-credential-401

Conversation

@mastacontrola

Copy link
Copy Markdown
Member

Found while probing the API token work. 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:

GET /fog/user                          -> 403, empty body
GET /fog/user  (bogus Bearer token)    -> 401

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 presented
anything" was written down as a rejected credential. _testAuth() already has
the 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:

Request Now Before
server token + valid basic auth 200 200
server token + wrong basic auth 401 401
no server token + valid basic auth 401 401
no auth at all 401 403
wrong fog-api-token 401 403
empty fog-api-token 401 403
bogus Bearer 401 401
system/info (unauthenticated route) 200 200

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 empty FOG_API_TOKEN matches an absent header, so
on a server with that setting blank _testToken() always passes. That is
not a bypass: _testToken() and _testAuth() both have to pass, so a user
credential 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-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.

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.

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>
@mastacontrola
mastacontrola merged commit df3ca89 into working-1.6 Aug 23, 2026
7 checks passed
@mastacontrola
mastacontrola deleted the fix-api-no-credential-401 branch August 23, 2026 23:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants