From ac4f923987162ddd094d36cdd5673460aa3088ae Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Sun, 16 Aug 2026 09:15:00 +0200 Subject: [PATCH 1/2] fix: SSO callbacks could be issued over http in production `SOCIAL_AUTH_REDIRECT_IS_HTTPS` was False everywhere. SSO callback and redirect URLs are built from the request, and behind a TLS-terminating proxy the request this process sees is plain HTTP -- so the URL handed to the identity provider could come out as `http://`. Either the provider rejects it for not matching its registered redirect URI and SSO simply breaks, or the exchange completes over an unprotected scheme with an authorization code in it. True in the production profile, where TLS terminates in front of this process by definition. The development default stays False, since a dev server speaks plain HTTP and forcing https makes SSO untestable locally; that is now written down rather than implied. `SECURE_PROXY_SSL_HEADER` is deliberately *not* set alongside it. It makes Django believe any request carrying `X-Forwarded-Proto: https`, and `PROXY_IP_ALLOWED_LIST` is empty by default -- proxy headers trusted unconditionally -- so enabling it without a trusted proxy in front lets a client assert its own scheme. It belongs in a conf.d override once the proxy is the only route in, and the production settings now say so with the pairing spelled out. --- forail/settings/defaults/social_auth.py | 4 ++++ forail/settings/production.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/forail/settings/defaults/social_auth.py b/forail/settings/defaults/social_auth.py index cce3e3f..317f665 100644 --- a/forail/settings/defaults/social_auth.py +++ b/forail/settings/defaults/social_auth.py @@ -51,6 +51,10 @@ SOCIAL_AUTH_CLEAN_USERNAMES = True SOCIAL_AUTH_SANITIZE_REDIRECTS = True +# False here is the development default: a dev server speaks plain HTTP, and +# forcing https on its callbacks makes SSO untestable locally. The production +# profile sets this to True -- see forail/settings/production.py for why, and +# for the SECURE_PROXY_SSL_HEADER caveat that goes with it. SOCIAL_AUTH_REDIRECT_IS_HTTPS = False # Note: These settings may be overridden by database settings. diff --git a/forail/settings/production.py b/forail/settings/production.py index dd957e7..bde95de 100644 --- a/forail/settings/production.py +++ b/forail/settings/production.py @@ -30,6 +30,25 @@ # See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts ALLOWED_HOSTS = [] +# SSO callback and redirect URLs are built from the request. Behind a +# TLS-terminating proxy the request python-social-auth sees is plain HTTP, so +# with this False the URL handed to the identity provider can come out as +# http:// -- which either breaks the flow against a provider that requires an +# exact https redirect URI, or completes an OAuth/SAML exchange over an +# unprotected scheme. In production TLS terminates in front of this process, so +# the answer is always https. +# +# Not paired with SECURE_PROXY_SSL_HEADER here on purpose. That setting makes +# Django believe any request carrying X-Forwarded-Proto: https, and +# PROXY_IP_ALLOWED_LIST is empty by default, meaning proxy headers are trusted +# unconditionally -- so enabling it without a trusted proxy in front lets a +# client assert its own scheme. Set it in a conf.d override once the proxy is +# the only route to this process: +# +# SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +# PROXY_IP_ALLOWED_LIST = ['10.0.1.100'] +SOCIAL_AUTH_REDIRECT_IS_HTTPS = True + # Ansible base virtualenv paths and enablement # only used for deprecated fields and management commands for them BASE_VENV_PATH = os.path.realpath("/var/lib/awx/venv") From 2951e6704d6df4a880b84512cf567f3b3562cf75 Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Tue, 18 Aug 2026 18:20:00 +0200 Subject: [PATCH 2/2] fix: import_from_awx had no way to pass a secret that is not exposed `--token` and `--password` were the only direct options, and both put a secret in the process list, in shell history and in process accounting -- readable by every other user on the box for as long as the import runs, which is not brief. The environment variables helped, but a secret in the environment is still readable through /proc for the same duration and is inherited by every child. Adds `--token-file` and `--password-file`, and an interactive `getpass` prompt when a username is given without a password. Resolution order is file, environment, then the deprecated flag; the prompt only fires when stdin is a terminal, so a non-interactive run fails with a message instead of blocking on a read that never returns. Missing basic-auth credentials now fail with that message rather than reaching the API with none. `--token` and `--password` still work and are marked DEPRECATED in `--help` and in the runtime warning, with the removal named as the next breaking release -- taking them away now would break every existing automation that calls this. --- .../management/commands/import_from_awx.py | 62 +++++++++++++++---- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/forail/main/management/commands/import_from_awx.py b/forail/main/management/commands/import_from_awx.py index 4d8bd7a..da1a7d4 100644 --- a/forail/main/management/commands/import_from_awx.py +++ b/forail/main/management/commands/import_from_awx.py @@ -34,9 +34,11 @@ --url https://awx.example.com --token $AWX_TOKEN --dry-run """ +import getpass import json import logging import os +import sys import requests @@ -129,11 +131,17 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--url', required=True, help='Base URL of the source AWX install, e.g. https://awx.example.com') - parser.add_argument('--token', help='OAuth2 token for the source AWX API (preferred). ' - 'Prefer the AWX_TOKEN env var — CLI args are visible in ps/proc.') + parser.add_argument('--token-file', help='File containing the OAuth2 token for the source AWX API. ' + 'The safest option: nothing is exposed in the process list, the ' + 'environment, or shell history.') + parser.add_argument('--password-file', help='File containing the password for basic auth.') + parser.add_argument('--token', help='DEPRECATED — OAuth2 token on the command line. Visible in ps/proc and ' + 'shell history; will be removed in the next breaking release. Use ' + '--token-file or the AWX_TOKEN env var.') parser.add_argument('--username', help='Username for basic auth (if no token). Env: AWX_USERNAME.') - parser.add_argument('--password', help='Password for basic auth (if no token). ' - 'Prefer the AWX_PASSWORD env var — CLI args are visible in ps/proc.') + parser.add_argument('--password', help='DEPRECATED — password on the command line, with the same exposure as ' + '--token. Use --password-file, the AWX_PASSWORD env var, or leave it ' + 'out and be prompted.') parser.add_argument('--insecure', action='store_true', help='Do not verify the source TLS certificate.') parser.add_argument('--dry-run', action='store_true', help='Fetch and report what would change, then roll back without writing.') parser.add_argument('--grant-superusers', action='store_true', @@ -148,20 +156,50 @@ def add_arguments(self, parser): parser.add_argument('--resource', action='append', choices=RESOURCE_ORDER, help='Limit to specific resource type(s); may be repeated. Default: all.') + def _read_secret_file(self, path, what): + """Read a secret from a file, trimming the trailing newline an editor adds.""" + try: + with open(path, 'r') as handle: + return handle.read().strip() + except OSError as exc: + raise CommandError(f'Could not read the {what} from {path}: {exc}') + def handle(self, *args, **options): - # M3: prefer secrets from the environment; CLI args leak via ps/proc, - # shell history and process accounting. - token = options.get('token') or os.environ.get('AWX_TOKEN') + # L2/M3: a secret on the command line is visible in ps/proc, in shell + # history and in process accounting, and it is visible to every other + # user on the box for as long as the import runs -- which is not brief. + # In order of preference: a file, the environment, an interactive + # prompt, and only then the deprecated flag. + token = None + if options.get('token_file'): + token = self._read_secret_file(options['token_file'], 'token') + token = token or os.environ.get('AWX_TOKEN') or options.get('token') + username = options.get('username') or os.environ.get('AWX_USERNAME') - password = options.get('password') or os.environ.get('AWX_PASSWORD') + + password = None + if options.get('password_file'): + password = self._read_secret_file(options['password_file'], 'password') + password = password or os.environ.get('AWX_PASSWORD') or options.get('password') + if options.get('token') or options.get('password'): self.stderr.write(self.style.WARNING( - 'Passing --token/--password on the command line is insecure (visible in ' - 'ps/proc and shell history). Prefer AWX_TOKEN / AWX_PASSWORD env vars.')) + 'DEPRECATED: --token/--password put a secret in the process list and shell ' + 'history, and will be removed in the next breaking release. Use --token-file / ' + '--password-file, or the AWX_TOKEN / AWX_PASSWORD environment variables.')) + + # Prompting is last, and only when someone is there to answer: a + # non-interactive run must fail with a usable message rather than block + # on a terminal read that never returns. + if not token and username and not password and sys.stdin is not None and sys.stdin.isatty(): + password = getpass.getpass(f'Password for {username} at {options["url"]}: ') if not token and not username: - raise CommandError('Provide a token (--token or AWX_TOKEN), or a username ' - '(--username/AWX_USERNAME with --password/AWX_PASSWORD).') + raise CommandError('Provide a token (--token-file, AWX_TOKEN or --token), or a username ' + '(--username/AWX_USERNAME with --password-file, AWX_PASSWORD or --password).') + if not token and not password: + raise CommandError('No password supplied for basic auth. Use --password-file, the AWX_PASSWORD ' + 'environment variable, or run interactively to be prompted.') # L1: --insecure sends the token / basic-auth credentials over a # connection with no certificate verification.