Summary
The |proxy_auth -i| ACL (case-insensitive user matching) is broken in
Squid 6.x. The |-i| flag causes entries to be lowercased during parse,
but the internal set container retains a case-sensitive comparator, so
lookups with mixed-case usernames always fail. |proxy_auth_regex -i| and
|proxy_auth| (without |-i|) work correctly.
Squid Version
* *Affected*: Squid 6.14-VCS (likely all Squid 6.x versions)
* *Working*: Squid 5.x (tested, |-i| works correctly)
Operating System
* Debian-based Linux (Artica Proxy appliance)
Steps to Reproduce
1.
Configure a negotiate (SPNEGO/NTLM) authentication helper that
returns usernames in mixed case (e.g., |DOMAIN/Username|)
2.
Create an ACL user list file |/etc/squid3/acls/userlist.txt|:
|DOMAIN/username DOMAIN/Username |
3.
Configure |proxy_auth -i| ACL:
|acl BlockedUsers proxy_auth -i "/etc/squid3/acls/userlist.txt"
http_access deny BlockedUsers |
4.
Authenticate via negotiate helper. The helper returns: |AF
oQcwBaADCgEA DOMAIN/Username|
5.
*Expected*: |proxy_auth -i| matches |DOMAIN/Username| against
|domain/username| (case-insensitive) → access denied
6.
*Actual*: |proxy_auth -i| does NOT match → access allowed
Workarounds
Any of these work:
* Use |proxy_auth| *without* |-i|, ensuring the ACL file contains
usernames in the exact case returned by the helper
* Use |proxy_auth_regex -i| instead (regex matching handles
case-insensitivity correctly)
Root Cause Analysis
The bug is in |src/acl/UserData.cc|. In Squid 6.x, the |-i| flag was
refactored into an ACL “line option” (handled by
|lineOptions()|/|Acl::BooleanOptionValue|). The |-i| token is consumed
by the line options parser *before* |parse()| is called. However, the
|parse()| function only recreates the internal |std::set| with a
case-insensitive comparator when it sees |-i| as a *token* — which never
happens in Squid 6.x because the token was already consumed.
Code trace in |UserData.cc|:
*Constructor* — set defaults to case-sensitive:
|ACLUserData::ACLUserData() : userDataNames(CaseSensitiveSBufCompare) //
case-SENSITIVE by default { ... } |
*parse()* — the set is never recreated:
|void ACLUserData::parse() { // Step 1: flag is set correctly from the
line option flags.case_insensitive = bool(CaseInsensitive_); // TRUE
(from -i line option) char *t = nullptr; if ((t =
ConfigParser::strtokFile())) { SBuf s(t); // Step 2: this check NEVER
matches in Squid 6.x because // "-i" was already consumed by the line
options parser if (s.cmp("-i",2) == 0) { flags.case_insensitive = true;
// THIS BLOCK NEVER EXECUTES — the set is never recreated
UserDataNames_t newUdn(CaseInsensitveSBufCompare);
newUdn.insert(userDataNames.begin(), userDataNames.end());
swap(userDataNames, newUdn); } else { // Step 3: entries ARE correctly
lowercased... if (flags.case_insensitive) s.toLower(); // ...but
inserted into a case-SENSITIVE set! userDataNames.insert(s); } } // ...
(remaining entries also lowercased and inserted into case-sensitive set) } |
*match()* — lookup uses case-sensitive comparison on lowercase entries:
|bool ACLUserData::match(char const *user) { // user = "DOMAIN/Username"
(original case from auth helper) // userDataNames contains
"domain/username" (lowercased during parse) // userDataNames comparator
= CaseSensitiveSBufCompare (never changed!) bool result =
(userDataNames.find(SBuf(user)) != userDataNames.end()); //
find("DOMAIN/Username") in set{"domain/username"} with CASE-SENSITIVE
compare // → NOT FOUND (because "DOMAIN/Username" != "domain/username")
return result; // returns false — BUG } |
In Squid 5.x (working correctly):
In Squid 5.x, |-i| was passed through to |parse()| as a regular token.
So the |if (s.cmp("-i",2) == 0)| block DID execute, and the set was
properly recreated with |CaseInsensitveSBufCompare|. The
case-insensitive comparator made |find()| work regardless of case.
In Squid 6.x, |-i| was refactored into a generic ACL “line option” — it
is now consumed by the line options parser *before* |parse()| is called.
The flag value is correctly propagated to |CaseInsensitive_| (and then
to |flags.case_insensitive|), and entries are correctly lowercased
during insertion. However, the set comparator is never switched from
case-sensitive to case-insensitive because the code path that does this
(|if (s.cmp("-i",2) == 0)|) is never reached.
Debug Evidence
Squid 6.14-VCS debug output (|debug_options ALL,1 28,9 29,9 84,9|):
*ACL parsing* — entries correctly lowercased, 2 unique users:
|UserData.cc(93) parse: parsing user list UserData.cc(99) parse: first
token is CHILD01/administrator UserData.cc(116) parse: Adding user
child01/administrator UserData.cc(121) parse: Case-insensitive-switch is
1 UserData.cc(124) parse: parsing following tokens UserData.cc(128)
parse: Got token: [email protected] UserData.cc(133) parse:
Adding user [email protected] UserData.cc(128) parse: Got
token: [email protected] UserData.cc(133) parse: Adding user
[email protected] UserData.cc(128) parse: Got token:
CHILD01/Administrator UserData.cc(133) parse: Adding user
child01/administrator UserData.cc(142) parse: ACL contains 2 users |
*Helper response* — username correctly extracted:
|helper.cc(1122) helperStatefulHandleRead: accumulated[38]=AF
oQcwBaADCgEA CHILD01/Administrator Reply.cc(43) finalize: Parsing helper
buffer UserRequest.cc(267) HandleReply: got reply={result=OK,
notes={token: oQcwBaADCgEA; user: CHILD01/Administrator; }}
UserRequest.cc(338) HandleReply: authenticated user
CHILD01/Administrator UserRequest.cc(357) HandleReply: Successfully
validated user via Negotiate. Username 'CHILD01/Administrator' |
*Match with |-i| — FAILS (BUG):*
|UserData.cc(26) match: user is CHILD01/Administrator, case_insensitive
is 1 UserData.cc(37) match: returning 0 |
*Match without |-i| — WORKS (proves the issue is with |-i|):*
|UserData.cc(26) match: user is CHILD01/Administrator, case_insensitive
is 0 UserData.cc(37) match: returning 1 |
Proof of Concept — Full Debug Session
Below is the complete step-by-step debug session performed on a live
Squid 6.14-VCS instance
to isolate and confirm the bug.
Environment
* *Squid server*: 192.168.60.58, Squid 6.14-VCS on Debian-based Linux
(Artica Proxy)
* *Client machine*: 192.168.60.31, Windows 10 domain-joined to
|CHILD01| (child domain of |ARTICATECH|)
* *Authentication*: Negotiate (SPNEGO with NTLM fallback) via external
helper
* *Auth helper*: Returns |AF <blob> CHILD01/Administrator| on
successful authenticate
Step 1 — Baseline configuration (broken)
ACL user list file (|/etc/squid3/acls/container_963.1.txt|):
|CHILD01/administrator [email protected]
[email protected] CHILD01/Administrator |
File verified clean with |xxd| — Unix line endings (0x0A), no BOM, no
hidden characters:
|00000000: 4348 494c 4430 312f 6164 6d69 6e69 7374 CHILD01/administ
00000010: 7261 746f 720a 6164 6d69 6e69 7374 7261 rator.administra
00000020: 746f 7240 6162 6f6c 696e 6861 732e 6c61 [email protected]
00000030: 620a 4164 6d69 6e69 7374 7261 746f 7240 b.Administrator@
00000040: 6162 6f6c 696e 6861 732e 6c61 620a 4348 abolinhas.lab.CH
00000050: 494c 4430 312f 4164 6d69 6e69 7374 7261 ILD01/Administra
00000060: 746f 720a tor. |
squid.conf ACL (in |/etc/squid3/http_access.conf|):
|acl AnnotateRule587 annotate_transaction accessrule=Rule587 acl
Group953 proxy_auth -i "/etc/squid3/acls/container_963.1.txt"
http_access deny Group953 AnnotateRule587 all |
Step 2 — Enable debug logging
|# Set debug options in /etc/squid3/logging.conf debug_options ALL,1
28,9 29,9 84,9 # Section 28 = Access Control (ACL matching, UserData) #
Section 29 = Authenticator (negotiate helper handling) # Section 84 =
Helper I/O (helper stdin/stdout) squid -k reconfigure |
Step 3 — Observe ACL parsing (cache.log)
Squid parses the ACL file with |-i| enabled. Entries are lowercased
during insertion.
After case-insensitive deduplication, the ACL contains 2 unique users:
|2026/03/04 18:23:57.734 kid1| 28,2| UserData.cc(93) parse: parsing user
list 2026/03/04 18:23:57.734 kid1| 28,5| UserData.cc(99) parse: first
token is CHILD01/administrator 2026/03/04 18:23:57.734 kid1| 28,6|
UserData.cc(116) parse: Adding user child01/administrator 2026/03/04
18:23:57.734 kid1| 28,3| UserData.cc(121) parse: Case-insensitive-switch
is 1 2026/03/04 18:23:57.734 kid1| 28,4| UserData.cc(124) parse: parsing
following tokens 2026/03/04 18:23:57.734 kid1| 28,6| UserData.cc(128)
parse: Got token: [email protected] 2026/03/04 18:23:57.734
kid1| 28,6| UserData.cc(133) parse: Adding user
[email protected] 2026/03/04 18:23:57.734 kid1| 28,6|
UserData.cc(128) parse: Got token: [email protected]
2026/03/04 18:23:57.734 kid1| 28,6| UserData.cc(133) parse: Adding user
[email protected] 2026/03/04 18:23:57.734 kid1| 28,6|
UserData.cc(128) parse: Got token: CHILD01/Administrator 2026/03/04
18:23:57.734 kid1| 28,6| UserData.cc(133) parse: Adding user
child01/administrator 2026/03/04 18:23:57.734 kid1| 28,4|
UserData.cc(142) parse: ACL contains 2 users |
Observation: |Case-insensitive-switch is 1| (flag is set correctly),
entries are lowercased
(|child01/administrator|, |[email protected]|). Parsing looks
correct.
Step 4 — Client authenticates via Negotiate (NTLM-in-SPNEGO)
Client on 192.168.60.31 sends CONNECT through the proxy. The negotiate
helper performs
two-step NTLM-in-SPNEGO authentication:
*Step 4a — NTLM Type 2 challenge (TT response):*
```
2026/03/04 18:29:02.696 kid1| 84,5| helper.cc(1112)
helperStatefulHandleRead: helperStatefulHandleRead: 376 bytes from
negotiateauthenticator #Hlpr91
2026/03/04 18:29:02.696 kid1| 84,3| Reply.cc(43) finalize: Parsing
helper buffer
2026/03/04 18:29:02.696 kid1| 29,8| UserRequest.cc(267) HandleReply:
hlpRes693 got reply={result=TT, notes={token: TlRMTVNTUA…AAAA=; }}
2026/03/04 18:29:02.696 kid1| 29,4| UserRequest.cc(313) HandleReply:
Need to challenge the client with a server token
|**Step 4b — NTLM Type 3 validation (AF response):** |
2026/03/04 18:29:02.724 kid1| 84,9| helper.cc(1447)
helperStatefulDispatch: helperStatefulDispatch busying helper
negotiateauthenticator #Hlpr91
2026/03/04 18:29:02.724 kid1| 84,5| helper.cc(1476)
helperStatefulDispatch: helperStatefulDispatch: Request sent to
negotiateauthenticator #Hlpr91, 764 bytes
2026/03/04 18:29:02.731 kid1| 84,5| helper.cc(1112)
helperStatefulHandleRead: helperStatefulHandleRead: 38 bytes from
negotiateauthenticator #Hlpr91
2026/03/04 18:29:02.732 kid1| 84,9| helper.cc(1122)
helperStatefulHandleRead: accumulated[38]=AF oQcwBaADCgEA
CHILD01/Administrator
2026/03/04 18:29:02.732 kid1| 84,3| helper.cc(1134)
helperStatefulHandleRead: helperStatefulHandleRead: end of reply found
2026/03/04 18:29:02.732 kid1| 84,3| Reply.cc(43) finalize: Parsing
helper buffer
2026/03/04 18:29:02.732 kid1| 84,3| Reply.cc(61) finalize: Buff length
is larger than 2
2026/03/04 18:29:02.732 kid1| 29,8| UserRequest.cc(267) HandleReply:
hlpRes693 got reply={result=OK, notes={token: oQcwBaADCgEA; user:
CHILD01/Administrator; }}
2026/03/04 18:29:02.732 kid1| 29,4| UserRequest.cc(338) HandleReply:
authenticated user CHILD01/Administrator
2026/03/04 18:29:02.732 kid1| 29,4| UserRequest.cc(357) HandleReply:
Successfully validated user via Negotiate. Username ‘CHILD01/Administrator’
|Observation: The helper returns exactly `AF oQcwBaADCgEA
CHILD01/Administrator` (38 bytes including newline). Squid correctly
parses the reply, extracts the token and username. The authenticated
username is set to `CHILD01/Administrator`. ### Step 5 — ACL match with
`-i` FAILS (the bug) When Squid evaluates `http_access deny Group953`,
it checks the `proxy_auth -i` ACL: |
2026/03/04 18:27:58.119 kid1| 28,7| UserData.cc(26) match: user is
CHILD01/Administrator, case_insensitive is 1
2026/03/04 18:27:58.119 kid1| 28,7| UserData.cc(37) match: returning 0
|**Result: `returning 0` — NO MATCH.** The ACL check for Group953
returns 0 (no match), so the deny rule does not trigger. Access is
allowed through the final allow rule: |
2026/03/04 18:29:02.732 kid1| 28,5| Acl.cc(145) matches: checking Group953
2026/03/04 18:29:02.732 kid1| 28,3| Acl.cc(172) matches: checked:
Group953 = 0
|Access log confirms user is NOT denied (note `accessrule: final_allow`): |
1772648820.870 75169 192.168.60.31 TCP_TUNNEL/200 5123 CONNECT
sapo.pt:443 CHILD01/Administrator HIER_DIRECT/213.13.145.114:443 -
accessrule:%20final_allow
|### Step 6 — Remove `-i` flag, ACL match SUCCEEDS Changed the ACL
definition to remove the `-i` flag: ```bash # Before: acl Group953
proxy_auth -i "/etc/squid3/acls/container_963.1.txt" # After: acl
Group953 proxy_auth "/etc/squid3/acls/container_963.1.txt" squid -k
reconfigure |
After the next authentication from the same client:
|2026/03/04 18:34:11.740 kid1| 28,7| UserData.cc(26) match: user is
CHILD01/Administrator, case_insensitive is 0 2026/03/04 18:34:11.740
kid1| 28,7| UserData.cc(37) match: returning 1 |
*Result: |returning 1| — MATCH!*
Without |-i|, entries are stored in original case from the file. The
file contains
|CHILD01/Administrator| (line 4) which exactly matches the helper’s
username. The deny
rule triggers.
Access log confirms user IS now denied (note |accessrule: Rule587| and
|ERR_ACCESS_DENIED|):
|1772649291.245 12 192.168.60.31 TCP_DENIED/200 0 CONNECT
static.foxnews.com:443 CHILD01/Administrator HIER_NONE/-:- -
accessrule:%20Rule587 ERR_ACCESS_DENIED 1772649291.188 2 192.168.60.31
NONE_NONE_ABORTED/403 65843 GET
https://static.foxnews.com/.../favicon-96x96.png CHILD01/Administrator
HIER_NONE/-:- text/html ERR_ACCESS_DENIED |
Step 7 — Additional confirmation: |proxy_auth_regex -i| also works
Changing the ACL to use |proxy_auth_regex -i| with the same file:
|acl Group953 proxy_auth_regex -i "/etc/squid3/acls/container_963.1.txt" |
This also correctly matches and denies the user. |proxy_auth_regex| is
unaffected
because it uses |ACLRegexData| (regex matching) instead of |ACLUserData|
(set-based matching).
Step 8 — Downgrade to Squid 5.x confirms fix
After downgrading from Squid 6.14-VCS to Squid 5.x on the same machine,
the original
configuration with |proxy_auth -i| works correctly — the deny rule
matches and blocks
the user as expected.
|acl Group953 proxy_auth -i "/etc/squid3/acls/container_963.1.txt" squid
-k reconfigure |
After the next authentication from the same client:
|2026/03/04 18:34:11.740 kid1| 28,7| UserData.cc(26) match: user is
CHILD01/Administrator, case_insensitive is 1 2026/03/04 18:34:11.740
kid1| 28,7| UserData.cc(37) match: returning 1 |
*Result: |returning 1| — MATCH!*
On Squd5.X With |-i|, entries are stored in original case from the file.
The file contains
|CHILD01/Administrator| (line 4) which exactly matches the helper’s
username. The deny
rule triggers.
Access log confirms user IS now denied (note |accessrule: Rule587| and
|ERR_ACCESS_DENIED|):
|1772649291.245 12 192.168.60.31 TCP_DENIED/200 0 CONNECT
static.foxnews.com:443 CHILD01/Administrator HIER_NONE/-:- -
accessrule:%20Rule587 ERR_ACCESS_DENIED 1772649291.188 2 192.168.60.31
NONE_NONE_ABORTED/403 65843 GET
https://static.foxnews.com/.../favicon-96x96.png CHILD01/Administrator
HIER_NONE/-:- text/html ERR_ACCESS_DENIED |
PoC Summary Table
Configuration Squid 6.14-VCS Squid 5.x
|proxy_auth -i| (file) BROKEN Works
|proxy_auth| (no |-i|, file) Works Works
|proxy_auth_regex -i| (file) Works Works
Suggested Fix
Either of these approaches would fix the bug:
Option A: Recreate the set when |CaseInsensitive_| is set (in
|parse()|)
Add set recreation at the beginning of |parse()| when the flag comes
from the line option:
|void ACLUserData::parse() { flags.case_insensitive =
bool(CaseInsensitive_); // If case-insensitive was set via line option,
ensure the set // uses the case-insensitive comparator if
(flags.case_insensitive) { UserDataNames_t
newUdn(CaseInsensitveSBufCompare); newUdn.insert(userDataNames.begin(),
userDataNames.end()); std::swap(userDataNames, newUdn); } // ... rest of
parse() unchanged } |
Option B: Lowercase the search key in |match()|
|bool ACLUserData::match(char const *user) { debugs(28, 7, "user is " <<
user << ", case_insensitive is " << flags.case_insensitive); if (user ==
nullptr || strcmp(user, "-") == 0) return 0; if (flags.required) {
debugs(28, 7, "aclMatchUser: user REQUIRED and auth-info present.");
return 1; } SBuf key(user); if (flags.case_insensitive) key.toLower();
bool result = (userDataNames.find(key) != userDataNames.end());
debugs(28, 7, "returning " << result); return result; } |
Option A is more correct because it also fixes the set comparator for
any future operations. Option B is simpler but relies on both sides
being lowercased rather than using a proper case-insensitive comparator.
Configuration Context
This was observed with a negotiate (SPNEGO/Kerberos with NTLM fallback)
authentication helper. The helper returns usernames in mixed case as
received from Active Directory SSPI (e.g., |DOMAIN/Username|). The
|proxy_auth -i| ACL should match these usernames case-insensitively
against the ACL file entries, but fails to do so in Squid 6.x.
|auth_param negotiate program /path/to/negotiate-helper acl BlockedUsers
proxy_auth -i "/path/to/userlist.txt" http_access deny BlockedUsers |
_______________________________________________
squid-users mailing list
[email protected]
https://lists.squid-cache.org/listinfo/squid-users