[squid-users] Bug Report: proxy_auth -i case-insensitive matching broken in Squid 6.x

Andre Bolinhas andre.bolinhas at articatech.com
Wed Mar 4 22:44:04 UTC 2026


    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: administrator at abolinhas.lab UserData.cc(133) parse: 
Adding user administrator at abolinhas.lab UserData.cc(128) parse: Got 
token: Administrator at abolinhas.lab UserData.cc(133) parse: Adding user 
administrator at abolinhas.lab 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 administrator at abolinhas.lab 
Administrator at abolinhas.lab 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 tor at abolinhas.la 
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: administrator at abolinhas.lab 2026/03/04 18:23:57.734 
kid1| 28,6| UserData.cc(133) parse: Adding user 
administrator at abolinhas.lab 2026/03/04 18:23:57.734 kid1| 28,6| 
UserData.cc(128) parse: Got token: Administrator at abolinhas.lab 
2026/03/04 18:23:57.734 kid1| 28,6| UserData.cc(133) parse: Adding user 
administrator at abolinhas.lab 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|, |administrator at abolinhas.lab|). 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 |
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.squid-cache.org/pipermail/squid-users/attachments/20260304/63d03c61/attachment-0001.htm>


More information about the squid-users mailing list