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

Reply via email to