├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── stale.yml └── workflows │ ├── python-package.yml │ └── python-publish.yml ├── .gitignore ├── .python-version ├── .readthedocs.yml ├── AUTHORS.md ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── TODO.txt ├── flake.lock ├── flake.nix ├── pyproject.toml ├── src └── pycookiecheat │ ├── __init__.py │ ├── __main__.py │ ├── chrome.py │ ├── common.py │ └── firefox.py ├── tests ├── test_chrome.py ├── test_common.py └── test_firefox.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | 10 | [*.py] 11 | indent_style = space 12 | indent_size = 4 13 | insert_final_newline = true 14 | 15 | [*.md] 16 | indent_style = space 17 | indent_size = 4 18 | 19 | [LICENSE] 20 | insert_final_newline = false 21 | 22 | [Makefile*] 23 | indent_style = tab 24 | 25 | [*.yml] 26 | indent_size = 2 27 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [n8henrie] 4 | custom: ["https://n8henrie.com/donate"] 5 | patreon: n8henrie 6 | open_collective: # Replace with a single Open Collective username 7 | ko_fi: # Replace with a single Ko-fi username 8 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 9 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 10 | liberapay: n8henrie 11 | issuehunt: # Replace with a single IssueHunt username 12 | otechie: # Replace with a single Otechie username 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | - **Operating system and version**: 2 | - **Python version**: 3 | - **pycookiecheat version**: 4 | 5 | ## My Issue 6 | 7 | 8 | 9 | ## [WHYT](https://web.archive.org/web/20140712194323/http://mattgemmell.com/what-have-you-tried/) 10 | 11 | 12 | 13 | --- 14 | 15 | Please make sure you've taken these steps before submitting a new issue: 16 | 17 | - [ ] Include the Python and pycookiecheat version in your issue 18 | - [ ] Ensure you're running a supported version of Python 19 | - [ ] Run pycookiecheat in debug mode if applicable and include 20 | relevant output 21 | - [ ] Search the existing (including closed) issues 22 | - [ ] Please use [codeblocks][1] for any code, config, program output, etc. 23 | 24 | [1]: https://help.github.com/articles/creating-and-highlighting-code-blocks/ 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | NB: *Please* make an issue prior to embarking on any significant effort towards 2 | a pull request. This will serve as a unified place for discussion and hopefully 3 | make sure that the change seems reasonable to merge once its implementation is 4 | complete. 5 | 6 | ## Description 7 | 8 | A few sentences describing the overall goals of the pull request's commits. If 9 | this is not an **extremely** trivial PR, *please* start an issue for discussion 10 | (hopefully prior to investing much time and effort). 11 | 12 | ## Status 13 | 14 | **READY/IN DEVELOPMENT** 15 | 16 | ## Related Issues 17 | 18 | - [First related issue](https://github.com/n8henrie/pycookiecheat/issues/) 19 | 20 | ## Todos 21 | 22 | - [ ] Tests 23 | - [ ] Documentation 24 | 25 | ## Steps to Test or Reproduce 26 | 27 | E.g.: 28 | 29 | ```bash 30 | git checkout -b master 31 | git pull https://github.com//pycookiecheat.git 32 | pytest tests/ 33 | ``` 34 | 35 | ## Other notes 36 | 37 | 38 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # https://probot.github.io/apps/stale/ 2 | # Number of days of inactivity before an issue becomes stale 3 | daysUntilStale: 14 4 | # Number of days of inactivity before a stale issue is closed 5 | daysUntilClose: 7 6 | # Issues with these labels will never be considered stale 7 | exemptLabels: 8 | - bug 9 | - wip 10 | # Label to use when marking an issue as stale 11 | staleLabel: stale 12 | # Comment to post when marking an issue as stale. Set to `false` to disable 13 | markComment: > 14 | This issue has been automatically marked as stale because it has not had 15 | recent activity or it has not had a response to a question in 14 days. It 16 | will be closed automatically if no further activity occurs within 7 days. 17 | Thank you for your contributions. 18 | # Comment to post when closing a stale issue. Set to `false` to disable 19 | closeComment: false 20 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: 15 | - "3.9" 16 | - "3.10" 17 | - "3.11" 18 | - "3.12" 19 | - "3.13" 20 | 21 | env: 22 | TOX_OVERRIDE: "testenv.pass_env+=XAUTHORITY,DISPLAY,TEST_BROWSER_NAME,TEST_BROWSER_PATH" 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | cache: 'pip' 31 | - name: Install tox 32 | run: python -m pip install .[test] 33 | - name: Install Brave 34 | # https://brave.com/linux/#release-channel-installation 35 | run: | 36 | sudo apt-get update 37 | sudo apt-get install -y apt-transport-https curl 38 | sudo curl -fsSLo /usr/share/keyrings/brave-browser-archive-keyring.gpg https://brave-browser-apt-release.s3.brave.com/brave-browser-archive-keyring.gpg 39 | echo "deb [signed-by=/usr/share/keyrings/brave-browser-archive-keyring.gpg] https://brave-browser-apt-release.s3.brave.com/ stable main" | 40 | sudo tee /etc/apt/sources.list.d/brave-browser-release.list 41 | sudo apt-get update 42 | sudo apt-get install --yes brave-browser 43 | - name: Run tests via tox (Chromium) 44 | run: xvfb-run -- python -m tox -e py 45 | - name: Run tests via tox (Brave) 46 | env: 47 | TEST_BROWSER_NAME: Brave 48 | TEST_BROWSER_PATH: /usr/bin/brave-browser 49 | run: xvfb-run -- python -m tox -e py 50 | - if: "matrix.python-version == '3.12'" 51 | name: Lint 52 | run: python -m tox -e lint 53 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: '3.x' 18 | cache: 'pip' 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install .[dev] 23 | - name: Build and publish 24 | env: 25 | TWINE_USERNAME: __token__ 26 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 27 | run: | 28 | python -m build 29 | python -m twine upload dist/* 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # Unit test / coverage reports 4 | .tox 5 | 6 | # vim temp files 7 | *.swp 8 | 9 | # build folders 10 | build/ 11 | dist/ 12 | docs/ 13 | *.egg-info/ 14 | cache/ 15 | .mypy_cache/ 16 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11.0 2 | 3.10.7 3 | 3.9.13 4 | 3.8.13 5 | 3.7.13 6 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | --- 5 | 6 | version: 2 7 | 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.10" 12 | 13 | sphinx: 14 | configuration: docs/conf.py 15 | 16 | python: 17 | install: 18 | - method: pip 19 | path: . 20 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | ## Core Contributors 4 | 5 | - [Nathan Henrie](http://n8henrie.com) 6 | - [jakob](https://github.com/grandchild) 7 | 8 | ## Other Contributors 9 | 10 | - 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [Changelog](https://keepachangelog.com) 2 | 3 | ## v0.8.0 :: 20241102 4 | 5 | ### Breaking Changes 6 | 7 | - `url` is now a positional argument (no longer requires `-u`) 8 | - Browser type must be passed as a variant of the `BrowserType` enum; string 9 | is no longer supported 10 | - Now requires python >= 3.9 11 | 12 | ### CLI Enhancements 13 | 14 | - Assume `https://` if the scheme is not specified 15 | - Add `--version` flag (thanks @samiam) 16 | - Add `-c` flag to specify custom path to cookie file (thanks @samiam) 17 | - Convert the `browser` argument into a `BrowserType` at parse time 18 | 19 | ### Fixes / Other 20 | 21 | - Fix new path to Firefox profile on MacOS (thanks @MattMuffin) 22 | - Support Chrome's new v24 cookies (thanks @chrisgavin) 23 | - Add new top-level `get_cookies` function that can be used for all supported 24 | browsers 25 | - No longer need to use separate `chrome_cookies` or `firefox_cookies` 26 | functions, but will leave these around for backwards compatibility 27 | - Use `ruff` instead of hodgepodge of `flake8` / `pycodestyle` / `black` and 28 | others 29 | 30 | ## v0.7.0 :: 20240105 31 | 32 | - Now requires python >= 3.8 33 | - 3.7 is now EoL: https://devguide.python.org/versions/ 34 | - pycookiecheat seems to build and run on 3.7, but several test 35 | dependencies require versions that are either incompatible with 3.12 or 36 | 3.7 37 | - Add `BrowserType` enum 38 | - Instead of passing a string (e.g. "chrome"), please import and use a 39 | `BrowserType` (e.g. `BrowserType.CHROME`) 40 | - Add deprecation warning for passing strings 41 | - Added a nix flake to facilitate testing multiple python versions 42 | - Add basic logging 43 | - Add CLI tool 44 | - Add `as_cookies` parameter to allow returning `list[Cookie]` instead of 45 | `dict` (without breaking backward compatibility) 46 | - Loosen dependency constrains, which should make usage as a library easier 47 | 48 | ## v0.6.0 :: 20230324 49 | 50 | - Add firefox support, thanks to @grandchild 51 | - Also would like to welcome @grandchild as a new member of the 52 | pycookiecheat team! 53 | 54 | ## v0.5.0 :: 20230324 55 | 56 | - Add support for Brave thanks to @chrisgavin! 57 | - Add support for Slack thanks to @hraftery! 58 | - Migrate config to pyproject.toml alone 59 | - Minor cleanup to codebase and tests 60 | 61 | ## v0.4.7 :: 20210826 62 | 63 | - No noteworthy API changes, hence the bugfix version bump, but some major 64 | infrastructure and testing updates: 65 | - Now uses GitHub Actions instead of Travis 66 | - Now uses Playwright for testing, to actually open a Chromium instance and 67 | use a real `Cookies` database 68 | - PEP517 69 | - black 70 | - Now requires python >= 3.7 71 | - This is largely due to requiremets of Playwright: 72 | https://pypi.org/project/playwright/, which is only a *test* dependency 73 | - Because I can't *test* with <=3.6, I'm not listing it as compatible, 74 | though it *probably* will still work 75 | - Migrate to pyproject.toml 76 | 77 | ## v0.4.6 :: 2019111 78 | 79 | - Try to open Chrome database in read-only mode to avoid db locked errors (#29) 80 | 81 | ## v0.4.5 :: 20191007 82 | 83 | - db6ac6d Go back to using cryptography due to 84 | https://www.cvedetails.com/cve/CVE-2013-7459/ 85 | - c70ad51 Allow users to override password (thanks @alairock) 86 | 87 | ## v0.4.4 :: 20180706 88 | 89 | - Optionally outputs cookies to a file compatible with cURL (thanks to 90 | Muntashir Al-Islam!) 91 | 92 | ## v0.4.3 :: 20170627 93 | 94 | - Consistently use Chrome as default across platforms, allow user to specify 95 | Chromium as desired (thanks @jtbraun) 96 | 97 | ## v0.4.0 :: 20170504 98 | 99 | - Remove compatibility for Python <3.5 100 | - Add type hints 101 | - Refactor for smaller functions 102 | - Expand docstrings 103 | - Revert from `cryptography` back to `PyCrypto` and `hashlib` for easier 104 | installation. 105 | 106 | ## v0.3.4 :: 20170414 107 | 108 | - Add support for new Ubuntu keyring / libsecret 109 | - See for details 110 | - Many thanks to @stat1c1c3au and @trideceth12 for contributions 111 | 112 | ## 0.3.0 113 | 114 | - Use [`cryptography`](https://cryptography.io/en/latest/) instead of 115 | `pycrypto` (thanks to [Taik](https://github.com/Taik)!) 116 | - Seems to be [significantly 117 | faster](https://github.com/n8henrie/pycookiecheat/pull/11#issuecomment-221950400) 118 | - Works with PyPy >= 2.6.0 (no support for PyPy3 yet) 119 | 120 | ## 0.2.0 121 | 122 | - Fix domain and subdomain matching 123 | - Make SQL query more secure by avoiding string formatting 124 | - Many thanks to [Brandon Rhodes](https://github.com/brandon-rhodes) for 24c4234 ! 125 | 126 | ## 0.1.10 127 | 128 | - Read version to separate file so it can be imported in setup.py 129 | - Bugfix for python2 on linux 130 | 131 | ## 0.1.9 132 | 133 | - Bugfix for python2 on linux 134 | 135 | ## 0.1.8 136 | 137 | - Python2 support (thanks [dani14-96](https://github.com/dani14-96)) 138 | 139 | ## 0.1.7 140 | 141 | - Configurable cookies file (thanks [ankostis](https://github.com/ankostis)) 142 | 143 | ## 0.1.6 144 | 145 | - OSError instead of Exception for wrong OS. 146 | - Moved testing requirements to tox and travis-ci files. 147 | 148 | ## 0.1.5 149 | 150 | - Updated to work better with PyPI's lack of markdown support 151 | - Working on tox and travis-ci integration 152 | - Added a few basic tests that should pass if one has Chrome installed and has visited my site (n8henrie.com) 153 | - Added sys.exit(0) if cookie_file not found so tests pass on travis-ci. 154 | 155 | ## 0.1.0 (2015-02-25) 156 | 157 | - First release on PyPI. 158 | 159 | ## Prior changelog from Gist 160 | 161 | - 20150221 v2.0.1: Now should find cookies for base domain and all subs. 162 | - 20140518 v2.0: Now works with Chrome's new encrypted cookies. 163 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Contributions are welcome, and they are greatly appreciated! Every 5 | little bit helps, and credit will always be given. 6 | 7 | You can contribute in many ways: 8 | 9 | Types of Contributions 10 | ---------------------- 11 | 12 | ### Report Bugs 13 | 14 | Report bugs at . 15 | 16 | If you are reporting a bug, please include: 17 | 18 | - Your operating system name and version. 19 | - Any details about your local setup that might be helpful in 20 | troubleshooting. 21 | - Detailed steps to reproduce the bug. 22 | 23 | ### Fix Bugs 24 | 25 | Look through the GitHub issues for bugs. Anything tagged with "bug" is 26 | open to whoever wants to implement it. 27 | 28 | ### Implement Features 29 | 30 | Look through the GitHub issues for features. Anything tagged with 31 | "feature" is open to whoever wants to implement it. 32 | 33 | ### Write Documentation 34 | 35 | pycookiecheat could always use more documentation, 36 | whether as part of the official pycookiecheat docs, 37 | in docstrings, or even on the web in blog posts, articles, and such. 38 | 39 | ### Submit Feedback 40 | 41 | The best way to send feedback is to file an issue at 42 | . 43 | 44 | If you are proposing a feature: 45 | 46 | - Explain in detail how it would work. 47 | - Keep the scope as narrow as possible, to make it easier to 48 | implement. 49 | - Remember that this is a volunteer-driven project, and that 50 | contributions are welcome :) 51 | 52 | Get Started! 53 | ------------ 54 | 55 | Ready to contribute? Here's how to set up pycookiecheat 56 | for local development. 57 | 58 | 1. Fork the pycookiecheat repo on GitHub. 59 | 1. Clone your fork locally: 60 | 61 | $ git clone git@github.com:your_name_here/pycookiecheat.git 62 | $ cd pycookiecheat 63 | 64 | 1. Install your local copy into a virtualenv (`venv` in modern python). Some 65 | linux distributions will require you to install `python-venv` or 66 | `python3-venv`, other times it will already be bundled with python. There 67 | are many ways to skin a cat, but this is how I usually set up a fork for 68 | local development: 69 | 70 | $ python3 -m venv .venv # set up hidden virtualenv folder: .venv 71 | $ source ./.venv/bin/actiate # activate virtualenv 72 | $ which python 73 | /Users/me/pycookiecheat/.venv/bin/python 74 | $ python -m pip install -e .[dev] # editable install with dev deps 75 | 76 | 1. Create a branch for local development: 77 | 78 | $ git checkout -b name-of-your-bugfix-or-feature # or use e.g. issue_13 79 | 80 | Now you can make your changes locally. 81 | 82 | 1. When you're done making changes, check that your changes pass flake8 83 | and the tests, including testing other Python versions with tox: 84 | 85 | $ tox 86 | 87 | 1. Commit your changes and push your branch to GitHub: 88 | 89 | $ git add . 90 | $ git commit -m "Your detailed description of your changes." 91 | $ git push origin name-of-your-bugfix-or-feature 92 | 93 | 1. Submit a pull request through the GitHub website against the `master` branch. 94 | 95 | Pull Request Guidelines 96 | ----------------------- 97 | 98 | Before you submit a pull request, check that it meets these guidelines: 99 | 100 | 1. The pull request should include tests. 101 | 2. If the pull request adds functionality, the docs should be updated. Put 102 | your new functionality into a function with a docstring, and add the 103 | feature to the list in README.md 104 | 3. The pull request should work for all Python versions that this project 105 | tests against with tox. Check 106 | and make sure 107 | that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests: `pytest tests/test_your_test.py` 113 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Nathan Henrie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pycookiecheat 2 | 3 | [![master branch build 4 | status](https://github.com/n8henrie/pycookiecheat/actions/workflows/python-package.yml/badge.svg?branch=master)](https://github.com/n8henrie/pycookiecheat/actions/workflows/python-package.yml) 5 | 6 | Borrow cookies from your browser's authenticated session for use in Python 7 | scripts. 8 | 9 | - Free software: MIT 10 | - [Documentation](https://n8henrie.com/2013/11/use-chromes-cookies-for-easier-downloading-with-python-requests/) 11 | 12 | ## Installation 13 | 14 | **NB:** Use `pip` and `python` instead of `pip3` and `python3` if you're still 15 | on Python 2 and using pycookiecheat < v0.4.0. pycookiecheat >= v0.4.0 requires 16 | Python 3 and in general will aim to support python versions that are stable and 17 | not yet end-of-life: . 18 | 19 | - `python3 -m pip install pycookiecheat` 20 | 21 | ### Installation notes regarding alternative keyrings on Linux 22 | 23 | See [#12](https://github.com/n8henrie/pycookiecheat/issues/12). Chrome is now 24 | using a few different keyrings to store your `Chrome Safe Storage` password, 25 | instead of a hard-coded password. Pycookiecheat doesn't work with most of these 26 | so far, and to be honest my enthusiasm for adding support for ones I don't use 27 | is limited. However, users have contributed code that seems to work with some 28 | of the recent Ubuntu desktops. To get it working, you may have to `sudo apt-get 29 | install libsecret-1-dev python-gi python3-gi`, and if you're installing into a 30 | virtualenv (highly recommended), you need to use the `--system-site-packages` 31 | flag to get access to the necessary libraries. 32 | 33 | Alternatively, some users have suggested running Chrome with the 34 | `--password-store=basic` or `--use-mock-keychain` flags. 35 | 36 | ### Development Setup 37 | 38 | 1. `git clone https://github.com/n8henrie/pycookiecheat.git` 39 | 1. `cd pycookiecheat` 40 | 1. `python3 -m venv .venv` 41 | 1. `./.venv/bin/python -m pip install -e .[dev]` 42 | 43 | ## Usage 44 | 45 | ### As a Command-Line Tool 46 | 47 | After installation, the CLI tool can be run as a python module `python -m` or 48 | with a standalone console script: 49 | 50 | ```console 51 | $ python -m pycookiecheat --help 52 | usage: pycookiecheat [-h] [-b BROWSER] [-o OUTPUT_FILE] [-v] [-c COOKIE_FILE] 53 | [-V] 54 | url 55 | 56 | Copy cookies from Chrome or Firefox and output as json 57 | 58 | positional arguments: 59 | url 60 | 61 | options: 62 | -h, --help show this help message and exit 63 | -b BROWSER, --browser BROWSER 64 | -o OUTPUT_FILE, --output-file OUTPUT_FILE 65 | Output to this file in netscape cookie file format 66 | -v, --verbose Increase logging verbosity (may repeat), default is 67 | `logging.ERROR` 68 | -c COOKIE_FILE, --cookie-file COOKIE_FILE 69 | Cookie file 70 | -V, --version show program's version number and exit 71 | 72 | ``` 73 | 74 | By default it prints the cookies to stdout as JSON but can also output a file in 75 | Netscape Cookie File Format. 76 | 77 | ### As a Python Library 78 | 79 | ```python 80 | from pycookiecheat import BrowserType, get_cookies 81 | import requests 82 | 83 | url = 'https://n8henrie.com' 84 | 85 | # Uses Chrome's default cookies filepath by default 86 | cookies = get_cookies(url) 87 | r = requests.get(url, cookies=cookies) 88 | 89 | # Using an alternate browser 90 | cookies = get_cookies(url, browser=BrowserType.CHROMIUM) 91 | ``` 92 | 93 | Use the `cookie_file` keyword-argument to specify a different path to the file 94 | containing your cookies: 95 | `get_cookies(url, cookie_file='/abspath/to/cookies')` 96 | 97 | You may be able to retrieve cookies for alternative Chromium-based browsers by 98 | manually specifying something like 99 | `"/home/username/.config/BrowserName/Default/Cookies"` as your `cookie_file`. 100 | 101 | ## Features 102 | 103 | - Returns decrypted cookies from Google Chrome, Brave, or Slack, on MacOS or 104 | Linux. 105 | - Optionally outputs cookies to file (thanks to Muntashir Al-Islam!) 106 | 107 | ## FAQ / Troubleshooting 108 | 109 | ### How about Windows? 110 | 111 | I don't use Windows or have a PC, so I won't be adding support myself. Feel 112 | free to make a PR :) 113 | 114 | ### I get an installation error with the `cryptography` module on OS X 115 | (pycookiecheat 65", "setuptools_scm[toml]>=7"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools] 6 | zip-safe = false 7 | 8 | [tool.setuptools.dynamic] 9 | version = {attr = "pycookiecheat.__version__"} 10 | readme = {file = ["README.md", "CHANGELOG.md"], content-type = "text/markdown"} 11 | 12 | [tool.mypy] 13 | check_untyped_defs = true 14 | disallow_untyped_calls = true 15 | disallow_untyped_defs = true 16 | follow_imports = "silent" 17 | ignore_missing_imports = true 18 | python_version = "3.9" 19 | show_column_numbers = true 20 | warn_incomplete_stub = false 21 | warn_redundant_casts = true 22 | warn_unused_ignores = true 23 | 24 | [tool.ruff] 25 | line-length = 79 26 | 27 | [tool.ruff.lint] 28 | select = ["I", "E4", "E7", "E9", "F"] 29 | 30 | [project] 31 | name = "pycookiecheat" 32 | urls = {homepage = "https://github.com/n8henrie/pycookiecheat"} 33 | dynamic = ["version", "readme"] 34 | license = { text = "MIT" } 35 | description = "Borrow cookies from your browser's authenticated session for use in Python scripts." 36 | authors = [ {name = "Nathan Henrie", email = "nate@n8henrie.com"} ] 37 | keywords = ["pycookiecheat", "chrome", "chromium cookies", "cookies", "firefox"] 38 | classifiers= [ 39 | "Natural Language :: English", 40 | "Programming Language :: Python :: 3", 41 | "Programming Language :: Python :: 3.9", 42 | "Programming Language :: Python :: 3.10", 43 | "Programming Language :: Python :: 3.11", 44 | "Programming Language :: Python :: 3.12", 45 | "Programming Language :: Python :: 3.13", 46 | ] 47 | dependencies = [ 48 | "cryptography==43.*", 49 | "keyring==25.*", 50 | ] 51 | 52 | [project.optional-dependencies] 53 | test = [ 54 | "mypy==1.*", 55 | "playwright==1.*", 56 | "pytest==8.*", 57 | "ruff==0.7.*", 58 | "tox==4.*", 59 | ] 60 | dev = [ 61 | "build==1.*", 62 | "twine==5.*", 63 | "wheel==0.43.*", 64 | ] 65 | 66 | [project.scripts] 67 | pycookiecheat = "pycookiecheat.__main__:main" 68 | -------------------------------------------------------------------------------- /src/pycookiecheat/__init__.py: -------------------------------------------------------------------------------- 1 | """__init__.py :: Exposes chrome_cookies function.""" 2 | 3 | from pycookiecheat.chrome import chrome_cookies 4 | from pycookiecheat.common import BrowserType, get_cookies 5 | from pycookiecheat.firefox import firefox_cookies 6 | 7 | __author__ = "Nathan Henrie" 8 | __email__ = "nate@n8henrie.com" 9 | __version__ = "v0.8.0" 10 | 11 | __all__ = [ 12 | "BrowserType", 13 | "chrome_cookies", 14 | "firefox_cookies", 15 | "get_cookies", 16 | ] 17 | -------------------------------------------------------------------------------- /src/pycookiecheat/__main__.py: -------------------------------------------------------------------------------- 1 | """Provide a command-line tool for pycookiecheat.""" 2 | 3 | import argparse 4 | import json 5 | import logging 6 | from importlib.metadata import version 7 | 8 | from .common import BrowserType, get_cookies 9 | 10 | 11 | def _cli() -> argparse.ArgumentParser: 12 | parser = argparse.ArgumentParser( 13 | prog="pycookiecheat", 14 | description="Copy cookies from Chrome or Firefox and output as json", 15 | ) 16 | parser.add_argument("url") 17 | parser.add_argument( 18 | "-b", "--browser", type=BrowserType, default=BrowserType.CHROME 19 | ) 20 | parser.add_argument( 21 | "-o", 22 | "--output-file", 23 | help="Output to this file in netscape cookie file format", 24 | ) 25 | parser.add_argument( 26 | "-v", 27 | "--verbose", 28 | action="count", 29 | default=0, 30 | help=( 31 | "Increase logging verbosity (may repeat), default is " 32 | "`logging.ERROR`" 33 | ), 34 | ) 35 | parser.add_argument( 36 | "-c", 37 | "--cookie-file", 38 | help="Cookie file", 39 | ) 40 | parser.add_argument( 41 | "-V", 42 | "--version", 43 | action="version", 44 | version=version(parser.prog), 45 | ) 46 | return parser 47 | 48 | 49 | def main() -> None: 50 | """Provide a main entrypoint for the command-line tool.""" 51 | parser = _cli() 52 | args = parser.parse_args() 53 | 54 | logging.basicConfig(level=max(logging.ERROR - 10 * args.verbose, 0)) 55 | 56 | # todo: make this a match statement once MSPV is 3.10 57 | browser = BrowserType(args.browser) 58 | 59 | cookies = get_cookies( 60 | url=args.url, 61 | browser=browser, 62 | curl_cookie_file=args.output_file, 63 | cookie_file=args.cookie_file, 64 | ) 65 | if not args.output_file: 66 | print(json.dumps(cookies, indent=4)) 67 | 68 | 69 | if __name__ == "__main__": 70 | main() 71 | -------------------------------------------------------------------------------- /src/pycookiecheat/chrome.py: -------------------------------------------------------------------------------- 1 | """pycookiecheat.py :: Retrieve and decrypt cookies from Chrome. 2 | 3 | See relevant post at https://n8henrie.com/2013/11/use-chromes-cookies-for-easier-downloading-with-python-requests/ # noqa 4 | 5 | Use your browser's cookies to make grabbing data from login-protected sites 6 | easier. Intended for use with Python Requests http://python-requests.org 7 | 8 | Accepts a URL from which it tries to extract a domain. If you want to force the 9 | domain, just send it the domain you'd like to use instead. 10 | """ 11 | 12 | from __future__ import annotations 13 | 14 | import logging 15 | import sqlite3 16 | import sys 17 | import typing as t 18 | from pathlib import Path 19 | 20 | import keyring 21 | from cryptography.hazmat.primitives.ciphers import Cipher 22 | from cryptography.hazmat.primitives.ciphers.algorithms import AES 23 | from cryptography.hazmat.primitives.ciphers.modes import CBC 24 | from cryptography.hazmat.primitives.hashes import SHA1 25 | from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC 26 | 27 | from pycookiecheat.common import ( 28 | BrowserType, 29 | Cookie, 30 | generate_host_keys, 31 | get_domain, 32 | write_cookie_file, 33 | ) 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | 38 | def clean(decrypted: bytes) -> str: 39 | r"""Strip padding from decrypted value. 40 | 41 | Remove number indicated by padding 42 | e.g. if last is '\x0e' then ord('\x0e') == 14, so take off 14. 43 | 44 | Args: 45 | decrypted: decrypted value 46 | Returns: 47 | decrypted, stripped of padding 48 | """ 49 | last = decrypted[-1] 50 | if isinstance(last, int): 51 | return decrypted[:-last].decode("utf8") 52 | 53 | try: 54 | cleaned = decrypted[: -ord(last)].decode("utf8") 55 | except UnicodeDecodeError: 56 | logging.error( 57 | "UTF8 decoding of the decrypted cookie failed. This is most often " 58 | "due to attempting decryption with an incorrect key. Consider " 59 | "searching the pycookiecheat issues for `UnicodeDecodeError`." 60 | ) 61 | raise 62 | 63 | return cleaned 64 | 65 | 66 | def chrome_decrypt( 67 | encrypted_value: bytes, 68 | key: bytes, 69 | init_vector: bytes, 70 | cookie_database_version: int, 71 | ) -> str: 72 | """Decrypt Chrome/Chromium's encrypted cookies. 73 | 74 | Args: 75 | encrypted_value: Encrypted cookie from Chrome/Chromium's cookie file 76 | key: Key to decrypt encrypted_value 77 | init_vector: Initialization vector for decrypting encrypted_value 78 | Returns: 79 | Decrypted value of encrypted_value 80 | """ 81 | # Encrypted cookies should be prefixed with 'v10' or 'v11' according to the 82 | # Chromium code. Strip it off. 83 | encrypted_value = encrypted_value[3:] 84 | 85 | cipher = Cipher( 86 | algorithm=AES(key), 87 | mode=CBC(init_vector), 88 | ) 89 | decryptor = cipher.decryptor() 90 | decrypted = decryptor.update(encrypted_value) + decryptor.finalize() 91 | 92 | if cookie_database_version >= 24: 93 | # Cookies in database version 24 and later include a SHA256 94 | # hash of the domain to the start of the encrypted value. 95 | # https://github.com/chromium/chromium/blob/280265158d778772c48206ffaea788c1030b9aaa/net/extras/sqlite/sqlite_persistent_cookie_store.cc#L223-L224 # noqa 96 | decrypted = decrypted[32:] 97 | 98 | return clean(decrypted) 99 | 100 | 101 | def get_macos_config(browser: BrowserType) -> dict: 102 | """Get settings for getting Chrome/Chromium cookies on MacOS. 103 | 104 | Args: 105 | browser: Enum variant representing browser of interest 106 | Returns: 107 | Config dictionary for Chrome/Chromium cookie decryption 108 | """ 109 | app_support = Path("Library/Application Support") 110 | # TODO: Refactor to exhaustive match statement once depending on >= 3.10 111 | try: 112 | cookies_suffix = { 113 | BrowserType.CHROME: "Google/Chrome/Default/Cookies", 114 | BrowserType.CHROMIUM: "Chromium/Default/Cookies", 115 | BrowserType.BRAVE: "BraveSoftware/Brave-Browser/Default/Cookies", 116 | BrowserType.SLACK: "Slack/Cookies", 117 | }[browser] 118 | except KeyError as e: 119 | errmsg = ( 120 | f"{browser} is not a valid BrowserType for {__name__}" 121 | ".get_macos_config" 122 | ) 123 | raise ValueError(errmsg) from e 124 | cookie_file = "~" / app_support / cookies_suffix 125 | 126 | # Slack cookies can be in two places on MacOS depending on whether it was 127 | # installed from the App Store or direct download. 128 | if browser is BrowserType.SLACK and not cookie_file.exists(): 129 | # And this location if Slack is installed from App Store 130 | cookie_file = ( 131 | "~/Library/Containers/com.tinyspeck.slackmacgap/Data" 132 | / app_support 133 | / cookies_suffix 134 | ) 135 | 136 | browser_name = browser.title() 137 | keyring_service_name = f"{browser_name} Safe Storage" 138 | 139 | keyring_username = browser_name 140 | if browser is BrowserType.SLACK: 141 | keyring_username = "Slack App Store Key" 142 | 143 | key_material = keyring.get_password(keyring_service_name, keyring_username) 144 | if key_material is None: 145 | errmsg = ( 146 | "Could not find a password for the pair " 147 | f"({keyring_service_name}, {keyring_username}). Please manually " 148 | "verify they exist in `Keychain Access.app`." 149 | ) 150 | raise ValueError(errmsg) 151 | 152 | config = { 153 | "key_material": key_material, 154 | "iterations": 1003, 155 | "cookie_file": cookie_file, 156 | } 157 | return config 158 | 159 | 160 | def get_linux_config(browser: BrowserType) -> dict: 161 | """Get the settings for Chrome/Chromium cookies on Linux. 162 | 163 | Args: 164 | browser: Enum variant representing browser of interest 165 | Returns: 166 | Config dictionary for Chrome/Chromium cookie decryption 167 | """ 168 | cookie_file = ( 169 | Path("~/.config") 170 | / { 171 | BrowserType.CHROME: "google-chrome/Default/Cookies", 172 | BrowserType.CHROMIUM: "chromium/Default/Cookies", 173 | BrowserType.BRAVE: "BraveSoftware/Brave-Browser/Default/Cookies", 174 | BrowserType.SLACK: "Slack/Cookies", 175 | }[browser] 176 | ) 177 | 178 | # Set the default linux password 179 | config = { 180 | "key_material": "peanuts", 181 | "iterations": 1, 182 | "cookie_file": cookie_file, 183 | } 184 | 185 | browser_name = browser.title() 186 | 187 | # Try to get pass from Gnome / libsecret if it seems available 188 | # https://github.com/n8henrie/pycookiecheat/issues/12 189 | key_material = None 190 | try: 191 | import gi 192 | 193 | gi.require_version("Secret", "1") 194 | from gi.repository import Secret 195 | except ImportError: 196 | logger.info("Was not able to import `Secret` from `gi.repository`") 197 | else: 198 | flags = Secret.ServiceFlags.LOAD_COLLECTIONS 199 | service = Secret.Service.get_sync(flags) 200 | 201 | gnome_keyring = service.get_collections() 202 | unlocked_keyrings = service.unlock_sync(gnome_keyring).unlocked 203 | 204 | # While Slack on Linux has its own Cookies file, the password 205 | # is stored in a keyring named the same as Chromium's, but with 206 | # an "application" attribute of "Slack". 207 | keyring_name = f"{browser_name} Safe Storage" 208 | 209 | for unlocked_keyring in unlocked_keyrings: 210 | for item in unlocked_keyring.get_items(): 211 | if item.get_label() == keyring_name: 212 | item_app = item.get_attributes().get( 213 | "application", browser 214 | ) 215 | if item_app.lower() != browser.lower(): 216 | continue 217 | item.load_secret_sync() 218 | key_material = item.get_secret().get_text() 219 | break 220 | else: 221 | # Inner loop didn't `break`, keep looking 222 | continue 223 | 224 | # Inner loop did `break`, so `break` outer loop 225 | break 226 | 227 | # Try to get pass from keyring, which should support KDE / KWallet 228 | # if dbus-python is installed. 229 | if key_material is None: 230 | try: 231 | key_material = keyring.get_password( 232 | f"{browser_name} Keys", 233 | f"{browser_name} Safe Storage", 234 | ) 235 | except RuntimeError: 236 | logger.info("Was not able to access secrets from keyring") 237 | 238 | # Overwrite the default only if a different password has been found 239 | if key_material is not None: 240 | config["key_material"] = key_material 241 | 242 | return config 243 | 244 | 245 | def chrome_cookies( 246 | url: str, 247 | *, 248 | browser: BrowserType = BrowserType.CHROME, 249 | as_cookies: bool = False, 250 | cookie_file: t.Optional[t.Union[str, Path]] = None, 251 | curl_cookie_file: t.Optional[t.Union[str, Path]] = None, 252 | password: t.Optional[t.Union[bytes, str]] = None, 253 | ) -> t.Union[dict, list[Cookie]]: 254 | """Retrieve cookies from Chrome/Chromium on MacOS or Linux. 255 | 256 | To facilitate comparison, please try to keep arguments in `chrome_cookies` 257 | and `firefox_cookies` ordered as: 258 | - `url`, `browser` 259 | - other parameters common to both above functions, alphabetical 260 | - parameters with unique to either above function, alphabetical 261 | 262 | Args: 263 | url: Domain from which to retrieve cookies, starting with http(s) 264 | browser: Enum variant representing browser of interest 265 | as_cookies: Return `list[Cookie]` instead of `dict` 266 | cookie_file: Path to alternate file to search for cookies 267 | curl_cookie_file: Path to save the cookie file to be used with cURL 268 | password: Optional system password 269 | Returns: 270 | Dictionary of cookie values for URL 271 | """ 272 | domain = get_domain(url) 273 | 274 | # Force a ValueError early if a string of an unrecognized browser is passed 275 | browser = BrowserType(browser) 276 | 277 | # If running Chrome on MacOS 278 | if sys.platform == "darwin": 279 | config = get_macos_config(browser) 280 | elif sys.platform.startswith("linux"): 281 | config = get_linux_config(browser) 282 | else: 283 | raise OSError("This script only works on MacOS or Linux.") 284 | 285 | config.update({ 286 | "init_vector": b" " * 16, 287 | "length": 16, 288 | "salt": b"saltysalt", 289 | }) 290 | 291 | if cookie_file is None: 292 | cookie_file = config["cookie_file"] 293 | cookie_file = Path(cookie_file) 294 | 295 | if isinstance(password, bytes): 296 | config["key_material"] = password 297 | elif isinstance(password, str): 298 | config["key_material"] = password.encode("utf8") 299 | elif isinstance(config["key_material"], str): 300 | config["key_material"] = config["key_material"].encode("utf8") 301 | 302 | kdf = PBKDF2HMAC( 303 | algorithm=SHA1(), 304 | iterations=config["iterations"], 305 | length=config["length"], 306 | salt=config["salt"], 307 | ) 308 | enc_key = kdf.derive(config["key_material"]) 309 | 310 | try: 311 | conn = sqlite3.connect( 312 | f"file:{cookie_file.expanduser()}?mode=ro", uri=True 313 | ) 314 | except sqlite3.OperationalError as e: 315 | logger.error("Unable to connect to cookie_file at %s", cookie_file) 316 | raise e 317 | 318 | conn.row_factory = sqlite3.Row 319 | conn.text_factory = bytes 320 | 321 | sql = "select value from meta where key = 'version';" 322 | cookie_database_version = 0 323 | try: 324 | row = conn.execute(sql).fetchone() 325 | if row: 326 | cookie_database_version = int(row[0]) 327 | else: 328 | logger.info("cookie database version not found in meta table") 329 | except sqlite3.OperationalError: 330 | logger.info("cookie database is missing meta table") 331 | 332 | # Check whether the column name is `secure` or `is_secure` 333 | secure_column_name = "is_secure" 334 | for ( 335 | sl_no, 336 | column_name, 337 | data_type, 338 | is_null, 339 | default_val, 340 | pk, 341 | ) in conn.execute("PRAGMA table_info(cookies)"): 342 | if column_name == "secure": 343 | secure_column_name = "secure AS is_secure" 344 | break 345 | 346 | sql = ( 347 | f"select host_key, path, {secure_column_name}, " 348 | "expires_utc, name, value, encrypted_value " 349 | "from cookies where host_key like ?" 350 | ) 351 | 352 | cookies: list[Cookie] = [] 353 | for host_key in generate_host_keys(domain): 354 | for db_row in conn.execute(sql, (host_key,)): 355 | # if there is a not encrypted value or if the encrypted value 356 | # doesn't start with the 'v1[01]' prefix, return v 357 | row = dict(db_row) 358 | if not row["value"] and ( 359 | row["encrypted_value"][:3] in {b"v10", b"v11"} 360 | ): 361 | row["value"] = chrome_decrypt( 362 | row["encrypted_value"], 363 | key=enc_key, 364 | init_vector=config["init_vector"], 365 | cookie_database_version=cookie_database_version, 366 | ) 367 | del row["encrypted_value"] 368 | for key, value in row.items(): 369 | if isinstance(value, bytes): 370 | row[key] = value.decode("utf8") 371 | cookies.append(Cookie(**row)) 372 | 373 | conn.rollback() 374 | 375 | if curl_cookie_file: 376 | write_cookie_file(curl_cookie_file, cookies) 377 | 378 | if as_cookies: 379 | return cookies 380 | 381 | return {c.name: c.value for c in cookies} 382 | -------------------------------------------------------------------------------- /src/pycookiecheat/common.py: -------------------------------------------------------------------------------- 1 | """Common code for pycookiecheat.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import typing as t 7 | import urllib.parse 8 | from dataclasses import dataclass 9 | from enum import Enum, unique 10 | from pathlib import Path 11 | from warnings import warn 12 | 13 | import pycookiecheat 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | @dataclass 19 | class Cookie: 20 | """Internal helper class used to represent a cookie only during processing. 21 | 22 | Cookies returned to the user from the public API are dicts, not instances 23 | of this class. 24 | """ 25 | 26 | name: str 27 | value: str 28 | host_key: str 29 | path: str 30 | expires_utc: int 31 | is_secure: int 32 | 33 | def as_cookie_file_line(self) -> str: 34 | """Return a string for a Netscape-style cookie file usable by curl. 35 | 36 | See details at http://www.cookiecentral.com/faq/#3.5 37 | """ 38 | return "\t".join([ 39 | self.host_key, 40 | "TRUE", 41 | self.path, 42 | "TRUE" if self.is_secure else "FALSE", 43 | str(self.expires_utc), 44 | self.name, 45 | self.value, 46 | ]) 47 | 48 | 49 | def generate_host_keys(hostname: str) -> t.Iterator[str]: 50 | """Yield keys for `hostname`, from least to most specific. 51 | 52 | Given a hostname like foo.example.com, this yields the key sequence: 53 | 54 | example.com 55 | .example.com 56 | foo.example.com 57 | .foo.example.com 58 | 59 | Treat "localhost" explicitly by returning only itself. 60 | """ 61 | if hostname == "localhost": 62 | yield hostname 63 | return 64 | 65 | labels = hostname.split(".") 66 | for i in range(2, len(labels) + 1): 67 | domain = ".".join(labels[-i:]) 68 | yield domain 69 | yield "." + domain 70 | 71 | 72 | def deprecation_warning(msg: str) -> None: 73 | """Raise a deprecation warning with the provided message. 74 | 75 | `stacklevel=3` tries to show the appropriate calling code to the user. 76 | """ 77 | warn(msg, DeprecationWarning, stacklevel=3) 78 | 79 | 80 | @unique 81 | class BrowserType(str, Enum): 82 | """Provide discrete values for recognized browsers. 83 | 84 | This utility class helps ensure that the requested browser is specified 85 | precisely or fails early by matching against user input; internally this 86 | enum should be preferred as compared to passing strings. 87 | 88 | >>> "chrome" is BrowserType.CHROME 89 | True 90 | 91 | TODO: consider using `enum.StrEnum` once pycookiecheat depends on python >= 92 | 3.11 93 | """ 94 | 95 | BRAVE = "brave" 96 | CHROME = "chrome" 97 | CHROMIUM = "chromium" 98 | FIREFOX = "firefox" 99 | SLACK = "slack" 100 | 101 | # https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides 102 | @classmethod 103 | def _missing_(cls, value: str) -> BrowserType: # type: ignore[override] 104 | """Provide case-insensitive matching for input values. 105 | 106 | >>> BrowserType("chrome") 107 | 108 | >>> BrowserType("FiReFoX") 109 | 110 | >>> BrowserType("edge") 111 | Traceback (most recent call last): 112 | ValueError: 'edge' is not a valid BrowserType 113 | """ 114 | folded = value.casefold() 115 | for member in cls: 116 | if member.value == folded: 117 | return member 118 | raise ValueError(f"{value!r} is not a valid {cls.__qualname__}") 119 | 120 | 121 | def write_cookie_file(path: Path | str, cookies: list[Cookie]) -> None: 122 | """Write cookies to a file in Netscape Cookie File format.""" 123 | path = Path(path) 124 | # Some programs won't recognize this as a valid cookie file without the 125 | # header 126 | output = ( 127 | "\n".join( 128 | ["# Netscape HTTP Cookie File"] 129 | + [c.as_cookie_file_line() for c in cookies] 130 | ) 131 | + "\n" 132 | ) 133 | path.write_text(output) 134 | 135 | 136 | def get_domain(url: str) -> str: 137 | """Return domain for url. 138 | 139 | If the scheme is not specified, `https://` is assumed. 140 | """ 141 | parsed_url = urllib.parse.urlparse(url) 142 | if not parsed_url.scheme: 143 | parsed_url = urllib.parse.urlparse(f"https://{url}") 144 | 145 | domain = parsed_url.netloc 146 | return domain 147 | 148 | 149 | def get_cookies( 150 | url: str, 151 | *, 152 | browser: BrowserType = BrowserType.CHROME, 153 | as_cookies: bool = False, 154 | cookie_file: t.Optional[t.Union[str, Path]] = None, 155 | curl_cookie_file: t.Optional[str] = None, 156 | password: t.Optional[t.Union[bytes, str]] = None, 157 | profile_name: t.Optional[str] = None, 158 | ) -> t.Union[dict, list[Cookie]]: 159 | """Retrieve cookies from supported browsers on MacOS or Linux. 160 | 161 | Common entrypoint that passes parameters on to `chrome_cookies` or 162 | `firefox_cookies` 163 | 164 | To facilitate comparison, please try to keep arguments ordered as: 165 | - `url`, `browser` 166 | - other parameters common to both above functions, alphabetical 167 | - parameters with unique to either above function, alphabetical 168 | 169 | Args: 170 | url: Domain from which to retrieve cookies, starting with http(s) 171 | browser: Enum variant representing browser of interest 172 | as_cookies: Return `list[Cookie]` instead of `dict` 173 | cookie_file: path to alternate file to search for cookies 174 | curl_cookie_file: Path to save the cookie file to be used with cURL 175 | password: Optional system password. Unused for Firefox. 176 | profile_name: Name (or glob pattern) of the Firefox profile to search 177 | for cookies -- if none given it will find the configured 178 | default profile. Unused for non-Firefox browsers. 179 | Returns: 180 | Dictionary of cookie values for URL 181 | """ 182 | if browser == BrowserType.FIREFOX: 183 | cookies = pycookiecheat.firefox_cookies( 184 | url, 185 | browser=browser, 186 | as_cookies=as_cookies, 187 | cookie_file=cookie_file, 188 | curl_cookie_file=curl_cookie_file, 189 | profile_name=profile_name, 190 | ) 191 | else: 192 | cookies = pycookiecheat.chrome_cookies( 193 | url, 194 | browser=browser, 195 | as_cookies=as_cookies, 196 | cookie_file=cookie_file, 197 | curl_cookie_file=curl_cookie_file, 198 | password=password, 199 | ) 200 | 201 | return cookies 202 | -------------------------------------------------------------------------------- /src/pycookiecheat/firefox.py: -------------------------------------------------------------------------------- 1 | """ 2 | Retrieve cookies from Firefox on various operating systems. 3 | 4 | Returns a dict of cookie names & values. 5 | 6 | Accepts a URL from which it tries to extract a domain. If you want to force the 7 | domain, just send it the domain you'd like to use instead. 8 | 9 | Example: 10 | >>> from pycookiecheat import firefox_cookies 11 | >>> firefox_cookies("https://github.com") 12 | {'logged_in': 'yes', 'user_session': 'n3tZzN45P56Ovg5MB'} 13 | """ 14 | 15 | from __future__ import annotations 16 | 17 | import configparser 18 | import logging 19 | import shutil 20 | import sqlite3 21 | import sys 22 | import tempfile 23 | import typing as t 24 | from pathlib import Path 25 | 26 | from pycookiecheat.common import ( 27 | BrowserType, 28 | Cookie, 29 | generate_host_keys, 30 | get_domain, 31 | write_cookie_file, 32 | ) 33 | 34 | logger = logging.getLogger(__name__) 35 | 36 | FIREFOX_COOKIE_SELECT_SQL = """ 37 | SELECT 38 | `host` AS host_key, 39 | name, 40 | value, 41 | `path`, 42 | isSecure AS is_secure, 43 | expiry AS expires_utc 44 | FROM moz_cookies 45 | WHERE host = ?; 46 | """ 47 | """ 48 | The query for selecting the cookies for a host. 49 | 50 | Rename some columns to match the Chrome cookie db row names. 51 | This makes the common.Cookie class simpler. 52 | """ 53 | 54 | FIREFOX_OS_PROFILE_DIRS: dict[str, dict[str, str]] = { 55 | "linux": { 56 | BrowserType.FIREFOX: "~/.mozilla/firefox", 57 | }, 58 | "macos": { 59 | BrowserType.FIREFOX: "~/Library/Application Support/Firefox", 60 | }, 61 | "windows": { 62 | BrowserType.FIREFOX: "~/AppData/Roaming/Mozilla/Firefox/Profiles", 63 | }, 64 | } 65 | 66 | 67 | class FirefoxProfileNotPopulatedError(Exception): 68 | """Raised when the Firefox profile has never been used.""" 69 | 70 | pass 71 | 72 | 73 | def _get_profiles_dir_for_os( 74 | os: str, browser: BrowserType = BrowserType.FIREFOX 75 | ) -> Path: 76 | """Retrieve the default directory containing the user profiles.""" 77 | try: 78 | os_config = FIREFOX_OS_PROFILE_DIRS[os] 79 | except KeyError: 80 | raise ValueError( 81 | f"OS must be one of {list(FIREFOX_OS_PROFILE_DIRS.keys())}" 82 | ) 83 | return Path(os_config[browser]).expanduser() 84 | 85 | 86 | def _find_firefox_default_profile(firefox_dir: Path) -> str: 87 | """ 88 | Return the name of the default Firefox profile. 89 | 90 | Args: 91 | firefox_dir: Path to the Firefox config directory 92 | Returns: 93 | Name of the default profile 94 | 95 | Firefox' profiles.ini file in the Firefox config directory that lists all 96 | available profiles. 97 | 98 | In Firefox versions 66 and below the default profile is simply marked with 99 | `Default=1` in the profile section. Firefox 67 started to support multiple 100 | installs of Firefox on the same machine and the default profile is now set 101 | in `Install...` sections. The install section contains the name of its 102 | default profile in the `Default` key. 103 | 104 | https://support.mozilla.org/en-US/kb/understanding-depth-profile-installation 105 | """ 106 | profiles_ini = configparser.ConfigParser() 107 | profiles_ini.read(firefox_dir / "profiles.ini") 108 | installs = [s for s in profiles_ini.sections() if s.startswith("Install")] 109 | if installs: # Firefox >= 67 110 | # Heuristic: Take the most recently created profile which should be the active one. 111 | return profiles_ini[installs[-1]]["Default"] 112 | else: # Firefox < 67 113 | profiles = [ 114 | s for s in profiles_ini.sections() if s.startswith("Profile") 115 | ] 116 | for profile in profiles: 117 | if profiles_ini[profile].get("Default") == "1": 118 | return profiles_ini[profile]["Path"] 119 | if profiles: 120 | return profiles_ini[profiles[0]]["Path"] 121 | raise Exception("no profiles found at {}".format(firefox_dir)) 122 | 123 | 124 | def _copy_if_exists(src: list[Path], dest: Path) -> None: 125 | for file in src: 126 | try: 127 | shutil.copy2(file, dest) 128 | except FileNotFoundError as e: 129 | logger.exception(e) 130 | 131 | 132 | def _load_firefox_cookie_db( 133 | profiles_dir: Path, 134 | tmp_dir: Path, 135 | profile_name: t.Optional[str] = None, 136 | cookie_file: t.Optional[t.Union[str, Path]] = None, 137 | ) -> Path: 138 | """ 139 | Return a file path to the selected browser profile's cookie database. 140 | 141 | Args: 142 | profiles_dir: Browser+OS paths profiles_dir path 143 | tmp_dir: A temporary directory to copy the DB file(s) into 144 | profile_name: Name (or glob pattern) of the Firefox profile to search 145 | for cookies -- if none given it will find the configured 146 | default profile 147 | cookie_file: optional custom path to a specific cookie file 148 | Returns: 149 | Path to the "deWAL'ed" temporary copy of cookies.sqlite 150 | 151 | Firefox stores its cookies in an SQLite3 database file. While Firefox is 152 | running it has an exclusive lock on this file and other processes can't 153 | read from it. To circumvent this, copy the cookies file to the given 154 | temporary directory and read it from there. 155 | 156 | The SQLite database uses a feature called WAL ("write-ahead logging") that 157 | writes transactions for the database into a second file _prior_ to writing 158 | it to the actual DB. When copying the database this method also copies the 159 | WAL file and then merges any outstanding writes, to make sure the cookies 160 | DB has the most recent data. 161 | """ 162 | 163 | if cookie_file: 164 | cookies_db = Path(cookie_file) 165 | cookies_wal = Path(cookies_db).parent / "cookies.sqlite-wal" 166 | 167 | else: 168 | if not profile_name: 169 | profile_name = _find_firefox_default_profile(profiles_dir) 170 | for profile_dir in profiles_dir.glob(profile_name): 171 | if (profile_dir / "cookies.sqlite").exists(): 172 | break 173 | else: 174 | raise FirefoxProfileNotPopulatedError(profiles_dir / profile_name) 175 | cookies_db = profile_dir / "cookies.sqlite" 176 | cookies_wal = profile_dir / "cookies.sqlite-wal" 177 | 178 | _copy_if_exists([cookies_db, cookies_wal], tmp_dir) 179 | db_file = tmp_dir / "cookies.sqlite" 180 | if not db_file.exists(): 181 | raise FileNotFoundError(f"no Firefox cookies DB in temp dir {tmp_dir}") 182 | with sqlite3.connect(db_file) as con: 183 | con.execute("PRAGMA journal_mode=OFF;") # merge WAL 184 | return db_file 185 | 186 | 187 | def firefox_cookies( 188 | url: str, 189 | *, 190 | browser: BrowserType = BrowserType.FIREFOX, 191 | as_cookies: bool = False, 192 | cookie_file: t.Optional[t.Union[str, Path]] = None, 193 | curl_cookie_file: t.Optional[str] = None, 194 | profile_name: t.Optional[str] = None, 195 | ) -> t.Union[dict, list[Cookie]]: 196 | """Retrieve cookies from Firefox on MacOS or Linux. 197 | 198 | To facilitate comparison, please try to keep arguments in `chrome_cookies` 199 | and `firefox_cookies` ordered as: 200 | - `url`, `browser` 201 | - other parameters common to both above functions, alphabetical 202 | - parameters with unique to either above function, alphabetical 203 | 204 | Args: 205 | url: Domain from which to retrieve cookies, starting with http(s) 206 | browser: Enum variant representing browser of interest 207 | as_cookies: Return `list[Cookie]` instead of `dict` 208 | cookie_file: path to alternate file to search for cookies 209 | curl_cookie_file: Path to save the cookie file to be used with cURL 210 | profile_name: Name (or glob pattern) of the Firefox profile to search 211 | for cookies -- if none given it will find the configured 212 | default profile 213 | Returns: 214 | Dictionary of cookie values for URL 215 | """ 216 | domain = get_domain(url) 217 | 218 | # Force a ValueError early if a string of an unrecognized browser is passed 219 | browser = BrowserType(browser) 220 | 221 | if sys.platform.startswith("linux"): 222 | os = "linux" 223 | elif sys.platform == "darwin": 224 | os = "macos" 225 | elif sys.platform == "win32": 226 | os = "windows" 227 | else: 228 | raise OSError( 229 | "This script only works on " 230 | + ", ".join(FIREFOX_OS_PROFILE_DIRS.keys()) 231 | ) 232 | 233 | profiles_dir = _get_profiles_dir_for_os(os, browser) 234 | 235 | cookies: list[Cookie] = [] 236 | with tempfile.TemporaryDirectory() as tmp_dir: 237 | db_file = _load_firefox_cookie_db( 238 | profiles_dir, Path(tmp_dir), profile_name, cookie_file 239 | ) 240 | for host_key in generate_host_keys(domain): 241 | with sqlite3.connect(db_file) as con: 242 | con.row_factory = sqlite3.Row 243 | res = con.execute(FIREFOX_COOKIE_SELECT_SQL, (host_key,)) 244 | for row in res.fetchall(): 245 | cookies.append(Cookie(**row)) 246 | 247 | if curl_cookie_file: 248 | write_cookie_file(curl_cookie_file, cookies) 249 | 250 | if as_cookies: 251 | return cookies 252 | 253 | return {c.name: c.value for c in cookies} 254 | -------------------------------------------------------------------------------- /tests/test_chrome.py: -------------------------------------------------------------------------------- 1 | """test_pycookiecheat.py :: Tests for pycookiecheat module.""" 2 | 3 | import os 4 | import sys 5 | import time 6 | import typing as t 7 | from pathlib import Path 8 | from tempfile import TemporaryDirectory 9 | from uuid import uuid4 10 | 11 | import pytest 12 | from playwright.sync_api import sync_playwright 13 | 14 | from pycookiecheat import BrowserType, chrome_cookies, get_cookies 15 | from pycookiecheat.chrome import get_linux_config, get_macos_config 16 | 17 | BROWSER = os.environ.get("TEST_BROWSER_NAME", "Chromium") 18 | 19 | 20 | @pytest.fixture(scope="module") 21 | def ci_setup() -> t.Generator: 22 | """Set up Chrome's cookies file and directory. 23 | 24 | Unfortunately, at least on MacOS 11, I haven't found a way to do this using 25 | a temporary directory or without accessing my actual keyring and profile. 26 | 27 | Things I've tried: 28 | - Use a temp directory for user-data-dir instead of actual Chrome 29 | profile 30 | - Seems not to work because the password is not correct for the 31 | profile. 32 | - Chrome generates a random password if one is not found for the 33 | profile, but this doesn't get added to Keychain and I haven't 34 | found a way to figure out what it's using for a particulary run 35 | 36 | Other notes: 37 | - Seems to require the "profile-directory" option instead of using the 38 | path to `Default` directly in user-data-dir 39 | - Seems to require a `max-age` for the cookie to last session to 40 | session 41 | 42 | https://chromium.googlesource.com/chromium/src/+/refs/heads/master/components/os_crypt/keychain_password_mac.mm 43 | """ 44 | with TemporaryDirectory() as cookies_home, sync_playwright() as p: 45 | ex_path = os.environ.get("TEST_BROWSER_PATH") 46 | browser = p.chromium.launch_persistent_context( 47 | cookies_home, 48 | headless=False, 49 | chromium_sandbox=False, 50 | args=["--no-sandbox", "--disable-setuid-sandbox"], 51 | ignore_default_args=[ 52 | "--use-mock-keychain", 53 | ], 54 | executable_path=ex_path, 55 | ) 56 | page = browser.new_page() 57 | page.goto("https://n8henrie.com") 58 | browser.add_cookies([ 59 | { 60 | "name": "test_pycookiecheat", 61 | "value": "It worked!", 62 | "domain": "n8henrie.com", 63 | "path": "/", 64 | "expires": int(time.time()) + 300, 65 | } 66 | ]) 67 | browser.close() 68 | cookie_file = Path(cookies_home) / "Default" / "Cookies" 69 | yield cookie_file 70 | 71 | 72 | def test_raises_on_empty() -> None: 73 | """Ensure that `chrome_cookies()` raises.""" 74 | with pytest.raises(TypeError): 75 | chrome_cookies() # type: ignore 76 | 77 | 78 | def test_no_cookies(ci_setup: str) -> None: 79 | """Ensure that no cookies are returned for a fake url.""" 80 | never_been_here = "http://{0}.com".format(uuid4()) 81 | empty_dict = chrome_cookies( 82 | never_been_here, 83 | cookie_file=ci_setup, 84 | browser=BrowserType(BROWSER), 85 | ) 86 | assert empty_dict == dict() 87 | 88 | 89 | def test_fake_cookie(ci_setup: str) -> None: 90 | """Tests a fake cookie from the website below. 91 | 92 | For this to pass, you'll have to visit the url and put in "TestCookie" and 93 | "Just_a_test!" to set a temporary cookie with the appropriate values. 94 | """ 95 | cookies = t.cast( 96 | dict, 97 | chrome_cookies( 98 | "https://n8henrie.com", 99 | cookie_file=ci_setup, 100 | browser=BrowserType(BROWSER), 101 | ), 102 | ) 103 | assert cookies.get("test_pycookiecheat") == "It worked!" 104 | 105 | assert cookies == get_cookies( 106 | "https://n8henrie.com", 107 | browser=BrowserType(BROWSER), 108 | cookie_file=ci_setup, 109 | ) 110 | 111 | 112 | def test_raises_on_wrong_browser() -> None: 113 | """Passing a browser other than Chrome or Chromium raises ValueError.""" 114 | with pytest.raises(ValueError): 115 | BrowserType("edge") 116 | 117 | with pytest.raises(ValueError): 118 | chrome_cookies( 119 | "https://n8henrie.com", 120 | browser="Safari", # type: ignore 121 | ) 122 | 123 | 124 | def test_slack_config() -> None: 125 | """Tests configuring for cookies from the macos Slack app. 126 | 127 | Hard to come up with a mock test, since the only functionality provided by 128 | the Slack app feature is to read cookies from a different file. So opt to 129 | just test that new functionality with something simple and fairly robust. 130 | """ 131 | cfgs = [] 132 | if sys.platform == "darwin": 133 | cfgs.append(get_macos_config(BrowserType.SLACK)) 134 | 135 | parent = Path( 136 | "~/Library/Application Support/BraveSoftware/Brave-Browser/Default" 137 | ) 138 | parent.mkdir(parents=True) 139 | (parent / "Cookies").touch() 140 | cfgs.append(get_macos_config(BrowserType.SLACK)) 141 | 142 | assert cfgs[0] != cfgs[1] 143 | else: 144 | cfgs.append(get_linux_config(BrowserType.SLACK)) 145 | 146 | for cfg in cfgs: 147 | assert "Slack" in str(cfg["cookie_file"]) 148 | 149 | 150 | def test_macos_bad_browser_variant() -> None: 151 | """Tests the error message resulting from unrecognized BrowserType.""" 152 | for invalid in [BrowserType.FIREFOX, "foo"]: 153 | with pytest.raises( 154 | ValueError, match=f"{invalid} is not a valid BrowserType" 155 | ): 156 | get_macos_config(invalid) # type: ignore 157 | -------------------------------------------------------------------------------- /tests/test_common.py: -------------------------------------------------------------------------------- 1 | """Tests for pycookiecheat.common.""" 2 | 3 | import typing as t 4 | 5 | import pytest 6 | 7 | from pycookiecheat.__main__ import _cli 8 | from pycookiecheat.common import ( 9 | BrowserType, 10 | Cookie, 11 | generate_host_keys, 12 | ) 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "is_secure,is_secure_str", 17 | [(0, "FALSE"), (1, "TRUE")], 18 | ) 19 | def test_cookie_as_cookie_file_line( 20 | is_secure: int, is_secure_str: str 21 | ) -> None: 22 | """Ensure that `Cookie.as_cookie_file_line()` returns a correct string.""" 23 | cookie = Cookie("foo", "bar", "a.com", "/", 0, is_secure) 24 | expected = f"a.com\tTRUE\t/\t{is_secure_str}\t0\tfoo\tbar" 25 | assert cookie.as_cookie_file_line() == expected 26 | 27 | 28 | @pytest.mark.parametrize( 29 | "host,host_keys", 30 | [ 31 | ( 32 | "example.org", 33 | [ 34 | "example.org", 35 | ".example.org", 36 | ], 37 | ), 38 | ( 39 | "foo.bar.example.org", 40 | [ 41 | "example.org", 42 | ".example.org", 43 | "bar.example.org", 44 | ".bar.example.org", 45 | "foo.bar.example.org", 46 | ".foo.bar.example.org", 47 | ], 48 | ), 49 | ("localhost", ["localhost"]), 50 | ], 51 | ) 52 | def test_generate_host_keys(host: str, host_keys: t.Iterable[str]) -> None: 53 | """Test `generate_host_keys()` with various example hostnames.""" 54 | assert list(generate_host_keys(host)) == host_keys 55 | 56 | 57 | def test_cli() -> None: 58 | """Test the cli. 59 | When cli tests fail, it probably means that examples in the readme need to 60 | be updated, and likely a "leftmost non-zero version number" bump to reflect 61 | an API change. 62 | """ 63 | args = _cli().parse_args(["https://n8henrie.com"]) 64 | assert args.url == "https://n8henrie.com" 65 | assert args.browser == BrowserType.CHROME 66 | 67 | args = _cli().parse_args(["github.com", "-vv"]) 68 | assert args.verbose == 2 69 | 70 | args = _cli().parse_args(["n8henrie.com", "--browser", "firefox"]) 71 | assert args.browser == BrowserType.FIREFOX 72 | -------------------------------------------------------------------------------- /tests/test_firefox.py: -------------------------------------------------------------------------------- 1 | """Tests for Firefox cookies & helper functions.""" 2 | 3 | import re 4 | import typing as t 5 | from datetime import datetime, timedelta 6 | from http.cookies import SimpleCookie 7 | from http.server import BaseHTTPRequestHandler, HTTPServer 8 | from pathlib import Path 9 | from textwrap import dedent 10 | from threading import Thread 11 | from unittest.mock import patch 12 | 13 | import pytest 14 | from playwright.sync_api import sync_playwright 15 | from pytest import FixtureRequest, TempPathFactory 16 | 17 | from pycookiecheat import BrowserType, firefox_cookies, get_cookies 18 | from pycookiecheat.firefox import ( 19 | FirefoxProfileNotPopulatedError, 20 | _find_firefox_default_profile, 21 | _get_profiles_dir_for_os, 22 | _load_firefox_cookie_db, 23 | ) 24 | 25 | TEST_PROFILE_NAME = "test-profile" 26 | TEST_PROFILE_DIR = f"1234abcd.{TEST_PROFILE_NAME}" 27 | 28 | PROFILES_INI_VERSION1 = dedent( 29 | f""" 30 | [General] 31 | StartWithLastProfile=1 32 | 33 | [Profile0] 34 | Name={TEST_PROFILE_NAME} 35 | IsRelative=1 36 | Path={TEST_PROFILE_DIR} 37 | Default=1 38 | 39 | [Profile1] 40 | Name={TEST_PROFILE_NAME}2 41 | IsRelative=1 42 | Path=abcdef01.{TEST_PROFILE_NAME}2 43 | """ 44 | ) 45 | 46 | PROFILES_INI_VERSION2 = dedent( 47 | f""" 48 | [Install8149948BEF895A0D] 49 | Default={TEST_PROFILE_DIR} 50 | Locked=1 51 | 52 | [General] 53 | StartWithLastProfile=1 54 | Version=2 55 | 56 | [Profile0] 57 | Name={TEST_PROFILE_NAME} 58 | IsRelative=1 59 | Path={TEST_PROFILE_DIR} 60 | Default=1 61 | """ 62 | ) 63 | 64 | PROFILES_INI_EMPTY = dedent( 65 | """ 66 | [General] 67 | StartWithLastProfile=1 68 | Version=2 69 | """ 70 | ) 71 | 72 | PROFILES_INI_VERSION1_NO_DEFAULT = dedent( 73 | f""" 74 | [General] 75 | StartWithLastProfile=1 76 | Version=2 77 | 78 | [Profile0] 79 | Name={TEST_PROFILE_NAME} 80 | IsRelative=1 81 | Path={TEST_PROFILE_DIR} 82 | """ 83 | ) 84 | 85 | PROFILES_INI_VERSION2_NO_DEFAULT = dedent( 86 | f""" 87 | [Install8149948BEF895A0D] 88 | Default={TEST_PROFILE_DIR} 89 | Locked=1 90 | 91 | [General] 92 | StartWithLastProfile=1 93 | Version=2 94 | 95 | [Profile0] 96 | Name={TEST_PROFILE_NAME} 97 | IsRelative=1 98 | Path={TEST_PROFILE_DIR} 99 | """ 100 | ) 101 | 102 | 103 | def _make_test_profiles( 104 | tmp_path: Path, profiles_ini_content: str, populate: bool = True 105 | ) -> t.Iterator[Path]: 106 | """Create a Firefox data dir with profile & (optionally) populate it. 107 | 108 | All of the fixtures using this function use the pytest builtin `tmp_path` 109 | or `tmp_path_factory` fixtures to create their temporary directories. 110 | """ 111 | profile_dir = tmp_path / TEST_PROFILE_DIR 112 | profile_dir.mkdir() 113 | (tmp_path / "profiles.ini").write_text(profiles_ini_content) 114 | if populate: 115 | with sync_playwright() as p: 116 | p.firefox.launch_persistent_context( 117 | user_data_dir=profile_dir, 118 | headless=True, 119 | ).close() 120 | with patch( 121 | "pycookiecheat.firefox._get_profiles_dir_for_os", 122 | return_value=tmp_path, 123 | ): 124 | yield tmp_path 125 | 126 | 127 | @pytest.fixture(scope="module") 128 | def profiles(tmp_path_factory: TempPathFactory) -> t.Iterator[Path]: 129 | """Create a Firefox data dir with profiles & cookie DBs.""" 130 | yield from _make_test_profiles( 131 | tmp_path_factory.mktemp("_"), PROFILES_INI_VERSION2 132 | ) 133 | 134 | 135 | @pytest.fixture( 136 | scope="module", 137 | params=[ 138 | PROFILES_INI_VERSION1, 139 | PROFILES_INI_VERSION2, 140 | PROFILES_INI_VERSION1_NO_DEFAULT, 141 | PROFILES_INI_VERSION2_NO_DEFAULT, 142 | ], 143 | ) 144 | def profiles_ini_versions( 145 | tmp_path_factory: TempPathFactory, request: FixtureRequest 146 | ) -> t.Iterator[Path]: 147 | """Create a Firefox data dir using varius `profiles.ini` types. 148 | 149 | Use different file format versions and contents. 150 | """ 151 | yield from _make_test_profiles(tmp_path_factory.mktemp("_"), request.param) 152 | 153 | 154 | @pytest.fixture(scope="module") 155 | def no_profiles(tmp_path_factory: TempPathFactory) -> t.Iterator[Path]: 156 | """Create a Firefox data dir with a `profiles.ini` with no profiles.""" 157 | yield from _make_test_profiles( 158 | tmp_path_factory.mktemp("_"), PROFILES_INI_EMPTY 159 | ) 160 | 161 | 162 | # TODO: Making this fixture module-scoped breaks the tests using the `profiles` 163 | # fixture. Find out why. 164 | @pytest.fixture 165 | def profiles_unpopulated(tmp_path: Path) -> t.Iterator[Path]: 166 | """Create a Firefox data dir with valid but upopulated `profiles.ini` file. 167 | 168 | "Unpopulated" means never actually used to launch Firefox with. 169 | """ 170 | yield from _make_test_profiles( 171 | tmp_path, PROFILES_INI_VERSION2, populate=False 172 | ) 173 | 174 | 175 | @pytest.fixture(scope="session") 176 | def cookie_server() -> t.Iterator[int]: 177 | """Start an `HTTPServer` on localhost which sets a cookie. 178 | 179 | Replies to GET requests by setting a "foo: bar" cookie. 180 | Used as fixture for testing cookie retrieval. 181 | 182 | Returns: 183 | The port of the server on localhost. 184 | """ 185 | 186 | class CookieSetter(BaseHTTPRequestHandler): 187 | def do_GET(self) -> None: # noqa: N802, must be named with HTTP verb 188 | self.send_response(200) 189 | cookie: SimpleCookie = SimpleCookie() 190 | cookie["foo"] = "bar" 191 | cookie["foo"]["path"] = "/" 192 | # Needs an expiry time, otherwise it's a session cookie, which are 193 | # never saved to disk. (Well, _technically_ they sometimes are, 194 | # when the browser is set to resume the session on restart, but we 195 | # aren't concerned with that here.) 196 | this_time_tomorrow = datetime.utcnow() + timedelta(days=1) 197 | cookie["foo"]["expires"] = this_time_tomorrow.strftime( 198 | "%a, %d %b %Y %H:%M:%S GMT" 199 | ) 200 | self.send_header("Set-Cookie", cookie["foo"].OutputString()) 201 | self.end_headers() 202 | 203 | def log_message(self, *_: t.Any) -> None: 204 | pass # Suppress logging 205 | 206 | with HTTPServer(("localhost", 0), CookieSetter) as server: 207 | Thread(target=server.serve_forever, daemon=True).start() 208 | yield server.server_port 209 | server.shutdown() 210 | 211 | 212 | @pytest.fixture 213 | def set_cookie(profiles: Path, cookie_server: int) -> t.Iterator[None]: 214 | """Launch Firefox and visit the cookie-setting server. 215 | 216 | The cookie is set, saved to the DB and the browser closes. Ideally the 217 | browser should still be running while the cookie tests run, but the 218 | synchronous playwright API doesn't support that. 219 | """ 220 | profile_dir = profiles / TEST_PROFILE_DIR 221 | with sync_playwright() as p, p.firefox.launch_persistent_context( 222 | user_data_dir=profile_dir 223 | ) as context: 224 | context.new_page().goto( 225 | f"http://localhost:{cookie_server}", 226 | # Fail quickly because it's localhost. If it's not there in 1s the 227 | # problem is the server or the test setup, not the network. 228 | timeout=1000, 229 | ) 230 | # This `yield` should be indented twice more, inside the launched 231 | # firefox context manager, but the synchronous playwright API doesn't 232 | # support it. This means the tests don't test getting cookies while 233 | # Firefox is running. 234 | # TODO: Try using the async playwright API instead. 235 | yield 236 | 237 | 238 | @pytest.mark.parametrize( 239 | "os_name,expected_dir", 240 | [ 241 | ("linux", "~/.mozilla/firefox"), 242 | ("macos", "~/Library/Application Support/Firefox"), 243 | ("windows", "~/AppData/Roaming/Mozilla/Firefox/Profiles"), 244 | ], 245 | ) 246 | def test_get_profiles_dir_for_os_valid( 247 | os_name: str, expected_dir: str 248 | ) -> None: 249 | """Test profile paths for each OS. 250 | 251 | Test only implicit "Firefox" default, since it's the only type we currently 252 | support. 253 | """ 254 | profiles_dir = _get_profiles_dir_for_os(os_name, BrowserType.FIREFOX) 255 | assert profiles_dir == Path(expected_dir).expanduser() 256 | 257 | 258 | def test_get_profiles_dir_for_os_invalid() -> None: 259 | """Test invalid OS and browser names.""" 260 | with pytest.raises(ValueError, match="OS must be one of"): 261 | _get_profiles_dir_for_os("invalid") 262 | with pytest.raises( 263 | ValueError, match="'invalid' is not a valid BrowserType" 264 | ): 265 | _get_profiles_dir_for_os("linux", BrowserType("invalid")) 266 | 267 | 268 | def test_firefox_get_default_profile_valid( 269 | profiles_ini_versions: Path, 270 | ) -> None: 271 | """Test discovering the default profile in a valid data dir.""" 272 | profile_dir = profiles_ini_versions / _find_firefox_default_profile( 273 | profiles_ini_versions 274 | ) 275 | assert profile_dir.is_dir() 276 | assert (profile_dir / "cookies.sqlite").is_file() 277 | 278 | 279 | def test_firefox_get_default_profile_invalid(no_profiles: Path) -> None: 280 | """Ensure profile discovery in an invalid data dir raises an exception.""" 281 | with pytest.raises(Exception, match="no profiles found"): 282 | _find_firefox_default_profile(no_profiles) 283 | 284 | 285 | def test_load_firefox_cookie_db_populated( 286 | tmp_path: Path, profiles: Path 287 | ) -> None: 288 | """Test loading Firefox cookies DB from a populated profile.""" 289 | db_path = _load_firefox_cookie_db(profiles, tmp_path) 290 | assert db_path == tmp_path / "cookies.sqlite" 291 | assert db_path.exists() 292 | 293 | 294 | @pytest.mark.parametrize("profile_name", [TEST_PROFILE_DIR, None]) 295 | def test_load_firefox_cookie_db_unpopulated( 296 | tmp_path: Path, 297 | profile_name: t.Optional[str], 298 | profiles_unpopulated: Path, 299 | ) -> None: 300 | """Test loading Firefox cookies DB from an unpopulated profile.""" 301 | with pytest.raises(FirefoxProfileNotPopulatedError): 302 | _load_firefox_cookie_db( 303 | profiles_unpopulated, 304 | tmp_path, 305 | profile_name, 306 | ) 307 | 308 | 309 | def test_load_firefox_cookie_db_copy_error( 310 | tmp_path: Path, profiles: Path 311 | ) -> None: 312 | """Test loading Firefox cookies DB when copying fails.""" 313 | # deliberately break copy function 314 | with patch("shutil.copy2"), pytest.raises( 315 | FileNotFoundError, match="no Firefox cookies DB in temp dir" 316 | ): 317 | _load_firefox_cookie_db( 318 | profiles, 319 | tmp_path, 320 | TEST_PROFILE_DIR, 321 | ) 322 | 323 | 324 | def test_firefox_cookies(set_cookie: None) -> None: 325 | """Test getting Firefox cookies after visiting a site with cookies.""" 326 | cookies = t.cast( 327 | dict, 328 | firefox_cookies("http://localhost", profile_name=TEST_PROFILE_DIR), 329 | ) 330 | assert len(cookies) > 0 331 | assert cookies["foo"] == "bar" 332 | 333 | assert cookies == get_cookies( 334 | "http://localhost", 335 | browser=BrowserType.FIREFOX, 336 | profile_name=TEST_PROFILE_DIR, 337 | ) 338 | 339 | 340 | def test_firefox_no_cookies(profiles: Path) -> None: 341 | """Ensure Firefox cookies for an unvisited site are empty.""" 342 | cookies = firefox_cookies( 343 | "http://example.org", profile_name=TEST_PROFILE_DIR 344 | ) 345 | assert len(cookies) == 0 346 | 347 | 348 | def test_firefox_cookies_curl_cookie_file( 349 | tmp_path: Path, set_cookie: None 350 | ) -> None: 351 | """Test getting Firefox cookies and saving them to a curl cookie file.""" 352 | cookie_file = tmp_path / "cookies.txt" 353 | firefox_cookies( 354 | "http://localhost", 355 | profile_name=TEST_PROFILE_DIR, 356 | curl_cookie_file=str(cookie_file), 357 | ) 358 | assert cookie_file.exists() 359 | assert re.fullmatch( 360 | ( 361 | r"# Netscape HTTP Cookie File\nlocalhost\tTRUE\t/\tFALSE\t[0-9]+" 362 | r"\tfoo\tbar\n" 363 | ), 364 | cookie_file.read_text(), 365 | ) 366 | 367 | 368 | @pytest.mark.parametrize("fake_os", ["linux", "darwin", "win32"]) 369 | def test_firefox_cookies_os(fake_os: str, profiles: Path) -> None: 370 | """Ensure the few lines of OS switching code are covered by a test.""" 371 | with patch("sys.platform", fake_os): 372 | cookies = firefox_cookies( 373 | "http://example.org", profile_name=TEST_PROFILE_DIR 374 | ) 375 | assert isinstance(cookies, dict) 376 | 377 | 378 | def test_firefox_cookies_os_invalid(profiles: Path) -> None: 379 | """Ensure an invalid OS raises an exception.""" 380 | with patch("sys.platform", "invalid"): 381 | with pytest.raises(OSError): 382 | firefox_cookies("http://localhost") 383 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py3{9,10,11,12,13},lint 3 | isolated_build = True 4 | 5 | [testenv] 6 | extras = test 7 | commands = 8 | python -m playwright install chromium 9 | python -m playwright install-deps chromium 10 | python -m playwright install --with-deps firefox 11 | python -m pytest {posargs:--verbose --showlocals} tests/ 12 | 13 | [testenv:lint] 14 | extras = test 15 | commands = 16 | ruff check 17 | mypy src/pycookiecheat/ tests/ 18 | 19 | [flake8] 20 | exclude = .svn,CVS,.bzr,.hg,.git,__pycache__,docs,venv,.venv,.tox,.eggs,build 21 | import-order-style = smarkets 22 | application-import-names = pycookiecheat 23 | --------------------------------------------------------------------------------