5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / vulnerable_handler.py PY
#!/usr/bin/env python3
"""
vulnerable_handler.py — Simulates AshAuthentication's vulnerable OAuth2/OIDC
user resolution logic (CVE-2026-49757).

This module reproduces the email-based upsert that AshAuthentication used
before the fix in versions 4.14.0 and 5.0.0-rc.10.

The vulnerable code path:
  1. IdentityChange.change/3 receives OAuth user_info from provider
  2. Upsert with upsert_identity: :unique_email matches on email
  3. SignInPreparation.prepare/3 creates session for matched user

Usage:
    from vulnerable_handler import VulnerableAuthHandler
    handler = VulnerableAuthHandler(db_path=":memory:")
"""

import sqlite3
import json
import hashlib
import secrets
from datetime import datetime, timezone


class VulnerableAuthHandler:
    """
    Simulates AshAuthentication's vulnerable OAuth2/OIDC callback handler.

    The vulnerability: user resolution is performed by email address
    (upsert_identity: :unique_email) instead of the (strategy, sub) pair.
    """

    def __init__(self, db_path=":memory:"):
        self.conn = sqlite3.connect(db_path, check_same_thread=False)
        self.conn.row_factory = sqlite3.Row
        self._init_db()

    def _init_db(self):
        self.conn.executescript("""
            CREATE TABLE IF NOT EXISTS users (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                email       TEXT UNIQUE NOT NULL,
                username    TEXT,
                role        TEXT DEFAULT 'user',
                created_at  TEXT DEFAULT (datetime('now'))
            );

            CREATE TABLE IF NOT EXISTS user_identities (
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
                strategy        TEXT NOT NULL,
                uid             TEXT NOT NULL,
                user_id         INTEGER NOT NULL REFERENCES users(id),
                user_info       TEXT,
                access_token    TEXT,
                refresh_token   TEXT,
                created_at      TEXT DEFAULT (datetime('now')),
                updated_at      TEXT DEFAULT (datetime('now'))
            );

            -- This is the vulnerable unique constraint: on email, not on (strategy, uid)
            CREATE UNIQUE INDEX IF NOT EXISTS idx_identities_email
                ON user_identities(
                    json_extract(user_info, '$.email')
                );
        """)
        self.conn.commit()

    def register_local_user(self, email, username=None, role="user"):
        cur = self.conn.execute(
            "INSERT INTO users (email, username, role) VALUES (?, ?, ?)",
            (email, username or email.split("@")[0], role),
        )
        self.conn.commit()
        return cur.lastrowid

    def oauth_callback(self, strategy_name, user_info, access_token=None,
                       refresh_token=None):
        """
        Simulates IdentityChange.change/3 + SignInPreparation.prepare/3.

        This is the VULNERABLE path — matches user by email.
        """
        email = user_info.get("email")
        sub = user_info.get("sub")
        email_verified = user_info.get("email_verified", False)

        print(f"  [handler] OAuth callback received:")
        print(f"  [handler]   strategy:       {strategy_name}")
        print(f"  [handler]   email:          {email}")
        print(f"  [handler]   sub:            {sub}")
        print(f"  [handler]   email_verified: {email_verified}")

        # Step 1: Upsert on :unique_email (VULNERABLE)
        existing = self.conn.execute(
            """SELECT ui.*, u.email as local_email, u.role
               FROM user_identities ui
               JOIN users u ON ui.user_id = u.id
               WHERE json_extract(ui.user_info, '$.email') = ?""",
            (email,),
        ).fetchone()

        if existing:
            user_id = existing["user_id"]
            print(f"  [handler] MATCH FOUND via email upsert:")
            print(f"  [handler]   user_id:    {user_id}")
            print(f"  [handler]   local_email: {existing['local_email']}")
            print(f"  [handler]   role:       {existing['role']}")

            self.conn.execute(
                """UPDATE user_identities
                   SET uid = ?, user_info = ?, access_token = ?,
                       refresh_token = ?, updated_at = datetime('now')
                   WHERE id = ?""",
                (sub, json.dumps(user_info), access_token,
                 refresh_token, existing["id"]),
            )
            self.conn.commit()
        else:
            # Also check if a local user already has this email
            local_user = self.conn.execute(
                "SELECT id FROM users WHERE email = ?", (email,),
            ).fetchone()

            if local_user:
                user_id = local_user["id"]
                print(f"  [handler] No identity match, but found local user_id={user_id}")
                print(f"  [handler] Linking OAuth identity to existing user")
            else:
                cur = self.conn.execute(
                    """INSERT INTO users (email, username)
                       VALUES (?, ?)""",
                    (email, email.split("@")[0]),
                )
                user_id = cur.lastrowid
                print(f"  [handler] No existing match — created new user_id={user_id}")

            self.conn.execute(
                """INSERT INTO user_identities
                   (strategy, uid, user_id, user_info, access_token, refresh_token)
                   VALUES (?, ?, ?, ?, ?, ?)""",
                (strategy_name, sub, user_id, json.dumps(user_info),
                 access_token, refresh_token),
            )
            self.conn.commit()

        return self._create_session(user_id)

    def _create_session(self, user_id):
        user = self.conn.execute(
            "SELECT * FROM users WHERE id = ?", (user_id,),
        ).fetchone()
        token = secrets.token_hex(16)
        print(f"  [handler] SignInPreparation: session created for user_id={user_id}")
        return {
            "session_token": token,
            "user_id": user_id,
            "email": user["email"],
            "username": user["username"],
            "role": user["role"],
        }

    def get_user(self, user_id):
        return self.conn.execute(
            "SELECT * FROM users WHERE id = ?", (user_id,),
        ).fetchone()

    def list_identities(self, user_id):
        return self.conn.execute(
            "SELECT * FROM user_identities WHERE user_id = ?", (user_id,),
        ).fetchall()

    def close(self):
        self.conn.close()


class FixedAuthHandler:
    """
    Simulates the PATCHED AshAuthentication handler (v4.14.0 / v5.0.0-rc.10).

    Uses (strategy, sub) as the primary lookup key.
    Applies on_untrusted_email_match policy for unknown subs.
    """

    def __init__(self, db_path=":memory:", on_untrusted_email_match="reject",
                 trust_email_verified=False):
        self.conn = sqlite3.connect(db_path, check_same_thread=False)
        self.conn.row_factory = sqlite3.Row
        self.on_untrusted_email_match = on_untrusted_email_match
        self.trust_email_verified = trust_email_verified
        self._init_db()

    def _init_db(self):
        self.conn.executescript("""
            CREATE TABLE IF NOT EXISTS users (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                email       TEXT UNIQUE NOT NULL,
                username    TEXT,
                role        TEXT DEFAULT 'user',
                created_at  TEXT DEFAULT (datetime('now'))
            );

            CREATE TABLE IF NOT EXISTS user_identities (
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
                strategy        TEXT NOT NULL,
                uid             TEXT NOT NULL,
                user_id         INTEGER NOT NULL REFERENCES users(id),
                user_info       TEXT,
                access_token    TEXT,
                refresh_token   TEXT,
                created_at      TEXT DEFAULT (datetime('now')),
                updated_at      TEXT DEFAULT (datetime('now'))
            );

            -- Fixed: unique constraint is on (strategy, uid), NOT email
            CREATE UNIQUE INDEX IF NOT EXISTS idx_identities_strategy_uid
                ON user_identities(strategy, uid);
        """)
        self.conn.commit()

    def register_local_user(self, email, username=None, role="user"):
        cur = self.conn.execute(
            "INSERT INTO users (email, username, role) VALUES (?, ?, ?)",
            (email, username or email.split("@")[0], role),
        )
        self.conn.commit()
        return cur.lastrowid

    def link_identity(self, strategy_name, sub, user_id, user_info,
                      access_token=None, refresh_token=None):
        self.conn.execute(
            """INSERT INTO user_identities
               (strategy, uid, user_id, user_info, access_token, refresh_token)
               VALUES (?, ?, ?, ?, ?, ?)""",
            (strategy_name, sub, user_id, json.dumps(user_info),
             access_token, refresh_token),
        )
        self.conn.commit()

    def oauth_callback(self, strategy_name, user_info, access_token=None,
                       refresh_token=None):
        """
        Simulates the FIXED UserResolver.resolve/3.

        1. Look up identity by (strategy, sub)
        2. If found → return linked user
        3. If not found → apply on_untrusted_email_match policy
        """
        email = user_info.get("email")
        sub = user_info.get("sub")
        email_verified = user_info.get("email_verified", False)

        print(f"  [handler] OAuth callback received:")
        print(f"  [handler]   strategy:       {strategy_name}")
        print(f"  [handler]   email:          {email}")
        print(f"  [handler]   sub:            {sub}")
        print(f"  [handler]   email_verified: {email_verified}")

        # Step 1: Resolve by (strategy, sub) — THE FIX
        identity = self.conn.execute(
            """SELECT ui.*, u.email as local_email, u.role
               FROM user_identities ui
               JOIN users u ON ui.user_id = u.id
               WHERE ui.strategy = ? AND ui.uid = ?""",
            (strategy_name, sub),
        ).fetchone()

        if identity:
            user_id = identity["user_id"]
            print(f"  [handler] Identity found via (strategy, sub):")
            print(f"  [handler]   user_id: {user_id}")
            print(f"  [handler]   email:   {identity['local_email']}")

            self.conn.execute(
                """UPDATE user_identities
                   SET access_token = ?, refresh_token = ?, updated_at = datetime('now')
                   WHERE id = ?""",
                (access_token, refresh_token, identity["id"]),
            )
            self.conn.commit()
            return self._create_session(user_id)

        # Step 2: Unknown sub — apply on_untrusted_email_match policy
        print(f"  [handler] No identity found for (strategy={strategy_name}, sub={sub})")

        if self.trust_email_verified and email_verified:
            print(f"  [handler] trust_email_verified=true and provider verified email")
            print(f"  [handler] Auto-linking by verified email...")
            return self._link_and_sign_in(strategy_name, sub, email, user_info,
                                          access_token, refresh_token)

        if self.on_untrusted_email_match == "reject":
            print(f"  [handler] on_untrusted_email_match = :reject")
            print(f"  [handler] LOGIN DENIED — no auto-linking by untrusted email")
            return {"error": "Authentication failed: untrusted email match"}

        elif self.on_untrusted_email_match == "confirm":
            print(f"  [handler] on_untrusted_email_match = :confirm")
            print(f"  [handler] Sending confirmation token to {email}...")
            token = secrets.token_hex(8)
            print(f"  [handler] Confirmation token: {token}")
            print(f"  [handler] Identity will be linked ONLY after email confirmation")
            return {
                "error": "Confirmation required",
                "confirmation_token": token,
                "email": email,
            }

        elif self.on_untrusted_email_match == "warn":
            print(f"  [handler] on_untrusted_email_match = :warn")
            print(f"  [handler] WARNING: Untrusted email match denied")
            return {"error": "Authentication failed: untrusted email match (warning logged)"}

        return {"error": "Unknown policy"}

    def _link_and_sign_in(self, strategy_name, sub, email, user_info,
                          access_token, refresh_token):
        existing_user = self.conn.execute(
            "SELECT * FROM users WHERE email = ?", (email,),
        ).fetchone()

        if existing_user:
            user_id = existing_user["id"]
        else:
            cur = self.conn.execute(
                "INSERT INTO users (email, username) VALUES (?, ?)",
                (email, email.split("@")[0]),
            )
            user_id = cur.lastrowid

        self.link_identity(strategy_name, sub, user_id, user_info,
                          access_token, refresh_token)
        return self._create_session(user_id)

    def _create_session(self, user_id):
        user = self.conn.execute(
            "SELECT * FROM users WHERE id = ?", (user_id,),
        ).fetchone()
        token = secrets.token_hex(16)
        print(f"  [handler] Session created for user_id={user_id}")
        return {
            "session_token": token,
            "user_id": user_id,
            "email": user["email"],
            "username": user["username"],
            "role": user["role"],
        }

    def get_user(self, user_id):
        return self.conn.execute(
            "SELECT * FROM users WHERE id = ?", (user_id,),
        ).fetchone()

    def close(self):
        self.conn.close()


if __name__ == "__main__":
    print("=" * 70)
    print("CVE-2026-49757 — Vulnerable Handler Module")
    print("This module simulates AshAuthentication's email-based user matching.")
    print("Use exploit.py to demonstrate the attack.")
    print("=" * 70)