anon.hash() is missing the RESTRICTED label, so a masked role can use it as a hashing oracle
Short version:
Every salt-reading SECURITY DEFINER function in the extension is labelled RESTRICTED so masked roles can't call it directly. Except anon.hash(), which has no label. So a masked role can call anon.hash() and use it to undo hashing and pseudonymization.
Walking through it:
anon.hash() (sql/hash.sql:73) is SECURITY DEFINER so it can read anon.salt, which is superuser-only. The pseudo functions read the salt the same way, and the comment at sql/pseudo.sql:8 says exactly why they're RESTRICTED: "to prevent a rogue masked user from brute forcing the function and reveal the secret salt." That label is on all ten pseudo_*() functions and on custom_value(). It was never added to hash().
The masking engine only blocks restricted functions. In src/hooks.rs:132 a masked user's query is rejected only when has_restricted_function() is true, and is_restricted_function() (src/rule/function.rs:46) returns true only when the function's label is RESTRICTED. anon.hash() has no label, so it returns false and the call goes through. You can see the asymmetry directly:
-- superuser, the recommended setup:
ALTER DATABASE app SET anon.salt TO 'a-real-secret';
SECURITY LABEL FOR anon ON ROLE analyst IS 'MASKED';
-- as analyst (a masked role):
SELECT anon.pseudo_first_name('x');
-- ERROR: role is masked (correct)
SELECT anon.hash('alice'); -- returns sha256('alice'||salt), should be blocked too
Once a masked role can call hash(), the salt stops protecting anything with a guessable value space. For a hashed column of first names, status codes, IDs in a known format, etc., you just compute anon.hash(candidate) for each guess and match it against the masked value. The salt's entropy doesn't matter for that. And with a handful of (input, hash) pairs you can brute-force or dictionary the salt offline and then reverse everything, pseudonymized columns included.
This only bites when a secret salt is configured, which is the setup masking_functions.md tells people to use.
The fix is the same pattern we already use everywhere else:
SECURITY LABEL FOR anon ON FUNCTION anon.hash(TEXT) IS 'RESTRICTED';
One related thing while I'm here: EXECUTE on these functions still defaults to PUBLIC. sql/anon.sql revokes the schema and tables from PUBLIC but not the functions, so a non-masked low-privilege role (a read-only app or reporting account) can call hash() and the pseudo_*() functions directly as well. A REVOKE ... FROM PUBLIC on the salt-reading functions would close that off.
Affects 3.0.13, and from the history it looks like earlier versions too.
Attack Scenarios
Scenario 1: Insider Threat (Direct Oracle) An authenticated contractor or read-only application account calls anon.hash() directly:
-- As non-superuser:
SELECT anon.hash('seed_alice');
-- Returns: sha256('seed_alice' || salt) — e.g., "f442220d5aa8b4f92c2daeff385dadd7..."
SELECT anon.hash('seed_bob');
SELECT anon.hash('seed_carol');
SELECT anon.hash('seed_dave');
The attacker collects (seed, hash_output) pairs and performs offline brute-force.
Scenario 2: SQL Injection Oracle If a web application logs hash mismatch errors (searched seed + computed hash), an attacker with SQL injection or insider access can read the logs directly:
SELECT searched_seed, hash_output FROM search_logs;
-- Returns all (seed, hash) pairs without calling anon.hash()
Scenario 3: Leaked Anonymized Dump A vendor or contractor with read access to an anonymized database dump calls pseudo_*() functions on known seed patterns to begin deanonymization:
SELECT anon.pseudo_email('alice@example.com');
SELECT anon.pseudo_email('bob@example.com');
-- Outputs match the masked values in the dump → identity mapping