diff --git a/lib/core/enums.py b/lib/core/enums.py index 6a5419117..26a0e8d50 100644 --- a/lib/core/enums.py +++ b/lib/core/enums.py @@ -463,6 +463,7 @@ class MKSTEMP_PREFIX(object): BIG_ARRAY = "sqlmapbigarray-" SPECIFIC_RESPONSE = "sqlmapresponse-" PREPROCESS = "sqlmappreprocess-" + AUTH_REFRESH = "sqlmapauthrefresh-" class TIMEOUT_STATE(object): NORMAL = 0 diff --git a/lib/core/option.py b/lib/core/option.py index 55f2f8af2..0af99b6fe 100644 --- a/lib/core/option.py +++ b/lib/core/option.py @@ -142,6 +142,7 @@ from lib.parse.configfile import configFileParser from lib.parse.payloads import loadBoundaries from lib.parse.payloads import loadPayloads from lib.request.basic import checkCharEncoding +from lib.request import authrefresh from lib.request.basicauthhandler import SmartHTTPBasicAuthHandler from lib.request.chunkedhandler import ChunkedHandler from lib.request.connect import Connect as Request @@ -1152,6 +1153,42 @@ def _setPostprocessFunctions(): errMsg += "(Note: find template script at '%s')" % filename raise SqlmapGenericException(errMsg) +def _setAuthRefreshFunction(): + """ + Load the authentication refresh hook without performing a login during initialization. + """ + + if conf.authRefresh: + try: + script = safeFilepathEncode(conf.authRefresh.strip()) + + if not os.path.isfile(script): + raise SqlmapFilePathException("auth refresh script '%s' does not exist" % script) + if not script.endswith(".py"): + raise SqlmapSyntaxException("auth refresh script should have an extension '.py'") + + dirname, filename = os.path.split(os.path.abspath(script)) + if dirname not in sys.path: + sys.path.insert(0, dirname) + + logger.info("loading auth refresh module '%s'" % filename[:-3]) + try: + getattr(importlib, "invalidate_caches", lambda: None)() + module = __import__(safeFilepathEncode(filename[:-3])) + function = getattr(module, "refresh", None) + if not inspect.isfunction(function): + raise ValueError("missing function 'refresh()'") + spec = _argSpec(function) + if len(spec.args) > len(spec.defaults or ()) or any(_ not in (getattr(spec, "kwonlydefaults", None) or {}) for _ in getattr(spec, "kwonlyargs", ())): + raise ValueError("function 'refresh()' must be callable without arguments") + except Exception: + raise SqlmapSyntaxException("unable to load 'refresh()' from auth refresh script '%s'" % script) + except (SqlmapFilePathException, SqlmapSyntaxException) as ex: + errMsg = "%s. You can find a template script at '%s'" % (getSafeExString(ex), authrefresh.createTemplate()) + raise type(ex)(errMsg) + + kb.authRefreshFunction = function + def _setThreads(): if not isinstance(conf.threads, int) or conf.threads <= 0: conf.threads = 1 @@ -2317,9 +2354,13 @@ def _setKnowledgeBaseAttributes(flushAll=True): kb.lastParserStatus = None kb.locks = AttribDict() - for _ in ("cache", "connError", "count", "handlers", "hint", "identYwaf", "index", "io", "limit", "liveCookies", "log", "multibit", "prediction", "socket", "redirect", "request", "value"): + for _ in ("authRefresh", "cache", "connError", "count", "handlers", "hint", "identYwaf", "index", "io", "limit", "liveCookies", "log", "multibit", "prediction", "socket", "redirect", "request", "value"): kb.locks[_] = threading.Lock() + kb.authRefreshHeaders = {} + kb.authRefreshVersion = 0 + kb.authRefreshFailed = False + kb.matchRatio = None kb.maxConnectionsFlag = False kb.trueLength = None @@ -2427,6 +2468,7 @@ def _setKnowledgeBaseAttributes(flushAll=True): kb.passwordMgr = None kb.postprocessFunctions = [] kb.preprocessFunctions = [] + kb.authRefreshFunction = None kb.skipVulnHost = None kb.storeCrawlingChoice = None kb.tamperFunctions = [] @@ -3171,6 +3213,7 @@ def init(): _setTamperingFunctions() _setPreprocessFunctions() _setPostprocessFunctions() + _setAuthRefreshFunction() _setTrafficOutputFP() _setupHTTPCollector() _setHttpOptions() diff --git a/lib/core/optiondict.py b/lib/core/optiondict.py index 76c6fb82c..afb3a65c8 100644 --- a/lib/core/optiondict.py +++ b/lib/core/optiondict.py @@ -257,8 +257,9 @@ optDict = { "hexConvert": "boolean", "outputDir": "string", "parseErrors": "boolean", - "postprocess": "string", + "authRefresh": "string", "preprocess": "string", + "postprocess": "string", "repair": "boolean", "reportJson": "string", "saveConfig": "string", diff --git a/lib/core/settings.py b/lib/core/settings.py index 1f7cdcb81..7916afa27 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from lib.core.enums import OS from thirdparty import six # sqlmap version (...) -VERSION = "1.10.10.9" +VERSION = "1.10.10.11" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) diff --git a/lib/core/threads.py b/lib/core/threads.py index a16be6042..6acf684ff 100644 --- a/lib/core/threads.py +++ b/lib/core/threads.py @@ -60,6 +60,7 @@ class _ThreadData(threading.local): self.lastHTTPError = None self.lastRedirectMsg = None self.lastQueryDuration = 0 + self.authRefreshTime = 0 self.lastPage = None self.lastRequestMsg = None self.lastRequestUID = 0 diff --git a/lib/parse/cmdline.py b/lib/parse/cmdline.py index acef3dc04..122e0303c 100644 --- a/lib/parse/cmdline.py +++ b/lib/parse/cmdline.py @@ -748,6 +748,9 @@ def cmdLineParser(argv=None): general.add_argument("--parse-errors", dest="parseErrors", action="store_true", help="Parse and display DBMS error messages from responses") + general.add_argument("--auth-refresh", dest="authRefresh", + help="Run script to refresh auth on HTTP 401") + general.add_argument("--preprocess", dest="preprocess", help="Use given script(s) for preprocessing (request)") diff --git a/lib/request/authrefresh.py b/lib/request/authrefresh.py new file mode 100644 index 000000000..7ccffd9fb --- /dev/null +++ b/lib/request/authrefresh.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re +import tempfile + +from lib.core.common import openFile +from lib.core.data import conf +from lib.core.data import kb +from lib.core.data import logger +from lib.core.enums import MKSTEMP_PREFIX +from lib.core.exception import SqlmapConnectionException +from lib.core.exception import SqlmapGenericException +from thirdparty import six +from thirdparty.six.moves import urllib as _urllib + + +def createTemplate(): + handle, filename = tempfile.mkstemp(prefix=MKSTEMP_PREFIX.AUTH_REFRESH, suffix=".py") + os.close(handle) + + with openFile(filename, "w+") as f: + f.write("#!/usr/bin/env\n\ndef refresh():\n # Obtain a fresh token here (use a timeout for login requests).\n # Custom headers also work, e.g. {'X-Auth-Token': token}.\n return {'Authorization': 'Bearer '}\n") + with openFile(os.path.join(os.path.dirname(filename), "__init__.py"), "w+") as f: + f.write("pass") + + return filename + + +def sameOrigin(first, second): + # checkSameHost deliberately ignores schemes and ports, which is unsuitable for credentials. + def origin(url): + parts = _urllib.parse.urlsplit(url) + return parts.scheme.lower(), parts.hostname, parts.port or (443 if parts.scheme.lower() == "https" else 80) + + return origin(first) == origin(second) + + +def apply(req): + """Overlay refreshed headers after preprocess hooks; return the credential generation sent.""" + + if not conf.authRefresh or not sameOrigin(req.get_full_url(), conf.url): + return None + + with kb.locks.authRefresh: + if kb.authRefreshFailed: + raise SqlmapGenericException("authentication refresh failed; cannot continue with expired credentials") + for name, value in kb.authRefreshHeaders.items(): + # Hooks/raw request files can leave differently-cased copies of the same header. + for headers in (req.headers, req.unredirected_hdrs): + for key in list(headers): + if key.lower() == name.lower(): + del headers[key] + req.add_header(name, value) + return kb.authRefreshVersion + + +def refresh(version): + """Refresh once for all workers that sent the same expired credentials.""" + + with kb.locks.authRefresh: + if kb.authRefreshFailed: + raise SqlmapConnectionException("authentication refresh failed; cannot continue with expired credentials") + if version != kb.authRefreshVersion: + return + + logger.info("refreshing authentication after HTTP 401") + try: + headers = kb.authRefreshFunction() + if not isinstance(headers, dict) or not headers: + raise ValueError() + for name, value in headers.items(): + if not isinstance(name, six.string_types) or not re.match(r"\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\Z", name): + raise ValueError() + if not isinstance(value, six.string_types) or not value or re.search(r"[\x00-\x1f\x7f]", value): + raise ValueError() + value.encode("latin-1") + if name.lower() in ("host", "content-length", "transfer-encoding", "connection", "proxy-authorization"): + raise ValueError() + if len(set(_.lower() for _ in headers)) != len(headers): + raise ValueError() + except Exception: + kb.authRefreshFailed = True + # Hook exceptions can contain tokens/passwords; do not include their text. + raise SqlmapConnectionException("auth refresh hook failed or returned invalid headers. Expected a non-empty dictionary of authentication headers. You can find a template script at '%s'" % createTemplate()) + + kb.authRefreshHeaders = dict(headers) + kb.authRefreshVersion += 1 + + +def stripHeaders(headers): + """Remove refreshed credentials before following a redirect to another origin.""" + + names = set(_.lower() for _ in kb.authRefreshHeaders) + return dict((name, value) for name, value in headers.items() if name.lower() not in names) diff --git a/lib/request/connect.py b/lib/request/connect.py index 455f5ccfc..04fc3f3a4 100644 --- a/lib/request/connect.py +++ b/lib/request/connect.py @@ -22,6 +22,7 @@ import time import traceback from lib.request import websocket +from lib.request import authrefresh from lib.request.websocket import WebSocketException from lib.core.agent import agent @@ -469,6 +470,8 @@ class Connect(object): responseHeaders = None logHeaders = u"" skipLogTraffic = False + authRetry = False + authVersion = None raise404 = raise404 and not kb.ignoreNotFound @@ -683,6 +686,9 @@ class Connect(object): else: post, headers = req.data, req.headers + authVersion = authrefresh.apply(req) + headers = req.headers + requestHeaders += "\r\n".join(["%s: %s" % (u"-".join(_.capitalize() for _ in getUnicode(key).split(u'-')) if hasattr(key, "capitalize") else getUnicode(key), getUnicode(value)) for (key, value) in req.header_items()]) if not getRequestHeader(req, HTTP_HEADER.COOKIE) and conf.cj: @@ -730,12 +736,12 @@ class Connect(object): raise SqlmapMissingDependence("native HTTP/2 client does not support SOCKS proxies (omit '--http2' or use an HTTP proxy)") try: - conn = http2OpenUrl(url, method or (HTTPMETHOD.POST if post is not None else HTTPMETHOD.GET), headers, post, timeout, follow_redirects=kb.choices.redirect != REDIRECTION.NO, proxy=h2proxy) + conn = http2OpenUrl(url, method or (HTTPMETHOD.POST if post is not None else HTTPMETHOD.GET), headers, post, timeout, follow_redirects=kb.choices.redirect != REDIRECTION.NO, proxy=h2proxy, sensitive_headers=tuple(kb.authRefreshHeaders) if conf.authRefresh else ()) except IOError as ex: raise _http_client.HTTPException(getSafeExString(ex)) else: if conn.code >= 400: - raise _urllib.error.HTTPError(url, conn.code, conn.msg, conn.info(), io.BytesIO(conn.read())) + raise _urllib.error.HTTPError(conn.geturl(), conn.code, conn.msg, conn.info(), io.BytesIO(conn.read())) requestMsg = re.sub(r" HTTP/[0-9.]+\r\n", " %s\r\n" % conn.http_version, requestMsg, count=1) @@ -863,7 +869,7 @@ class Connect(object): page = None responseHeaders = None - if checking: + if checking and not (ex.code == _http_client.UNAUTHORIZED and authVersion is not None): return None, None, None try: @@ -887,7 +893,8 @@ class Connect(object): code = ex.code status = getUnicode(getattr(ex, "reason", None) or getSafeExString(ex).split(": ", 1)[-1]) - kb.originalCode = kb.originalCode or code + if not (code == _http_client.UNAUTHORIZED and authVersion is not None): + kb.originalCode = kb.originalCode or code threadData.lastHTTPError = (threadData.lastRequestUID, code, status) kb.httpErrorCodes[code] = kb.httpErrorCodes.get(code, 0) + 1 @@ -913,7 +920,15 @@ class Connect(object): singleTimeLogMessage(errMsg, logging.CRITICAL) raise SystemExit - if ex.code not in (conf.ignoreCode or []): + if ex.code == _http_client.UNAUTHORIZED and authVersion is not None and authrefresh.sameOrigin(ex.geturl(), url): + if kwargs.get("authRetried"): + with kb.locks.authRefresh: + if authVersion == kb.authRefreshVersion: + kb.authRefreshFailed = True + raise SqlmapConnectionException("authentication refresh did not authorize the retried request (401)") + authrefresh.refresh(authVersion) + authRetry = True + elif ex.code not in (conf.ignoreCode or []): if ex.code == TOO_MANY_REQUESTS_HTTP_CODE or (ex.code == _http_client.SERVICE_UNAVAILABLE and Connect._parseRetryAfter(responseHeaders) is not None): retried = Connect._rateLimitRetry(responseHeaders, ex.code, **kwargs) if retried is not None: @@ -1099,6 +1114,12 @@ class Connect(object): socket.setdefaulttimeout(conf.timeout) + if authRetry: + # Authentication latency is not evidence of a time-based SQL injection. + threadData.authRefreshTime += calculateDeltaSeconds(start) + kwargs["authRetried"] = True + return Connect.getPage(**kwargs) + # Dirty patch for Python3.11.0a7 (e.g. https://github.com/sqlmapproject/sqlmap/issues/5091) if not sys.version.startswith("3.11."): if conf.retryOn and re.search(conf.retryOn, page or "", re.I): @@ -1751,6 +1772,8 @@ class Connect(object): start = time.time() + authRefreshTime = threadData.authRefreshTime + if kb.nullConnection and not content and not response and not timeBasedCompare: noteResponseTime = False @@ -1806,7 +1829,7 @@ class Connect(object): return value page, headers, code = Connect.getPage(url=_(kb.secondReq[0]), post=_(kb.secondReq[2]), method=kb.secondReq[1], cookie=kb.secondReq[3], silent=silent, auxHeaders=dict(auxHeaders, **dict(kb.secondReq[4])), response=response, raise404=False, ignoreTimeout=timeBasedCompare, refreshing=True) - threadData.lastQueryDuration = calculateDeltaSeconds(start) + threadData.lastQueryDuration = max(0, calculateDeltaSeconds(start) - (threadData.authRefreshTime - authRefreshTime)) kb.originalCode = code if kb.originalCode is None else kb.originalCode kb.originalPage = page if kb.originalPage is None else kb.originalPage diff --git a/lib/request/http2.py b/lib/request/http2.py index 7b787f90a..fc8ba0fd7 100644 --- a/lib/request/http2.py +++ b/lib/request/http2.py @@ -1576,7 +1576,7 @@ def proxy_tuple(proxy, cred=None): def open_url(url, method="GET", headers=None, body=None, timeout=30, follow_redirects=True, - max_redirects=10, proxy=None, verify=False, ssl_context=None): + max_redirects=10, proxy=None, verify=False, ssl_context=None, sensitive_headers=()): req_headers = headers or {} current_method = method current_body = body @@ -1614,7 +1614,7 @@ def open_url(url, method="GET", headers=None, body=None, timeout=30, follow_redi old_parts = parts new_parts = urlsplit(new_url) if _origin_tuple(old_parts) != _origin_tuple(new_parts): - req_headers = _headers_without(req_headers, _SENSITIVE_REDIRECT_FIELDS) + req_headers = _headers_without(req_headers, _SENSITIVE_REDIRECT_FIELDS.union(_as_bytes(_).lower() for _ in sensitive_headers)) method_bytes = _as_bytes(current_method) change_to_get = status == 303 and method_bytes != b"HEAD" diff --git a/lib/request/redirecthandler.py b/lib/request/redirecthandler.py index 33d498627..32f65f992 100644 --- a/lib/request/redirecthandler.py +++ b/lib/request/redirecthandler.py @@ -32,6 +32,7 @@ from lib.core.settings import MAX_TOTAL_REDIRECTIONS from lib.core.threads import getCurrentThreadData from lib.request.basic import decodePage from lib.request.basic import parseResponse +from lib.request import authrefresh from thirdparty import six from thirdparty.six.moves import http_client as _http_client from thirdparty.six.moves import urllib as _urllib @@ -176,6 +177,9 @@ class SmartRedirectHandler(_urllib.request.HTTPRedirectHandler): req.headers[HTTP_HEADER.COOKIE] = delimiter.join("%s=%s" % (key, cookies[key]) for key in cookies) try: + if conf.authRefresh and not authrefresh.sameOrigin(req.get_full_url(), redurl): + req.headers = authrefresh.stripHeaders(req.headers) + req.unredirected_hdrs = authrefresh.stripHeaders(req.unredirected_hdrs) result = _urllib.request.HTTPRedirectHandler.http_error_302(self, req, fp, code, msg, headers) except _urllib.error.HTTPError as ex: result = error = ex diff --git a/sqlmap.conf b/sqlmap.conf index 2f7c22d28..742a9d143 100644 --- a/sqlmap.conf +++ b/sqlmap.conf @@ -809,6 +809,9 @@ outputDir = # Valid: True or False parseErrors = False +# Run a Python refresh() script returning replacement headers on HTTP 401. +authRefresh = + # Use given script(s) for preprocessing of request. preprocess = diff --git a/tests/test_authrefresh.py b/tests/test_authrefresh.py new file mode 100644 index 000000000..5aff5d687 --- /dev/null +++ b/tests/test_authrefresh.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Authentication refresh hook loading and replay against a local HTTP server. +""" + +import json +import os +import re +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.data import conf, kb, paths, cmdLineOptions +from lib.core.enums import MKSTEMP_PREFIX, REDIRECTION +from lib.core.exception import SqlmapConnectionException, SqlmapFilePathException, SqlmapSyntaxException +from lib.core.option import _setAuthRefreshFunction, _setKnowledgeBaseAttributes +from lib.core.threads import getCurrentThreadData +from lib.request import authrefresh +from lib.request.connect import Connect +from lib.request.redirecthandler import SmartRedirectHandler +from thirdparty.six.moves import BaseHTTPServer, socketserver +from thirdparty.six.moves import urllib as _urllib + +_GET_PAGE = Connect.__dict__['getPage'] +_QUERY_PAGE = Connect.__dict__['queryPage'] + + +def _assertTemplate(testCase, exception): + match = re.search(r"template script at '([^']+)'", str(exception)) + testCase.assertIsNotNone(match) + path = match.group(1) + try: + testCase.assertTrue(os.path.basename(path).startswith(MKSTEMP_PREFIX.AUTH_REFRESH)) + testCase.assertTrue(os.path.isfile(os.path.join(os.path.dirname(path), '__init__.py'))) + with open(path) as stream: + source = stream.read() + namespace = {} + eval(compile(source, path, 'exec'), namespace) + testCase.assertEqual(namespace['refresh'](), {'Authorization': 'Bearer '}) + testCase.assertNotIn(paths.SQLMAP_EXTRAS_PATH, path) + finally: + os.unlink(path) + + +class _Server(socketserver.ThreadingMixIn, BaseHTTPServer.HTTPServer): + daemon_threads = True + + +class _Handler(BaseHTTPServer.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + self.do_POST() + + def do_POST(self): + body = self.rfile.read(int(self.headers.get('Content-Length', '0'))) + token = self.headers.get('X-Auth-Token') + if self.path == '/refresh': + self.server.refreshes += 1 + code, body = 200, b'{"token":"fresh"}' + else: + with self.server.lock: + self.server.requests.append((self.path, body, token)) + if token != self.server.token: + self.server.expired += 1 + if self.server.expired >= 2: + self.server.bothExpired.set() + if token != self.server.token and self.server.syncExpired: + self.server.bothExpired.wait(3) + code = 200 if token == self.server.token else 401 + body = b'ok' if code == 200 else b'expired' + self.send_response(302 if self.path == '/redirect' and code == 200 else code) + if self.path == '/redirect' and code == 200: + self.send_header('Location', self.server.redirect) + self.send_header('Content-Type', 'text/plain; charset=utf-8') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + +class TestAuthRefresh(unittest.TestCase): + def setUp(self): + self.conf = dict(conf) + self.cmdLineOptions = dict(cmdLineOptions) + cmdLineOptions.method = None + self.kb = dict(kb) + self.threadData = dict(getCurrentThreadData().__dict__) + getCurrentThreadData().__dict__.clear() + getCurrentThreadData().reset() + self.timeout = socket.getdefaulttimeout() + self.urlopen = _urllib.request.urlopen + # Other engine tests replace these descriptors; use the real methods even when an + # earlier Python 2 fixture restored a bare function instead of a staticmethod. + self.methods = Connect.__dict__['getPage'], Connect.__dict__['queryPage'] + Connect.getPage, Connect.queryPage = _GET_PAGE, _QUERY_PAGE + _setKnowledgeBaseAttributes(False) + kb.wizardMode = True + self.server = _Server(('127.0.0.1', 0), _Handler) + self.server.requests = [] + self.server.refreshes = 0 + self.server.token = 'fresh' + self.server.expired = 0 + self.server.syncExpired = False + self.server.bothExpired = threading.Event() + self.server.lock = threading.Lock() + self.thread = threading.Thread(target=self.server.serve_forever) + self.thread.daemon = True + self.thread.start() + conf.url = 'http://127.0.0.1:%d/test' % self.server.server_port + conf.hostname, conf.scheme, conf.port = '127.0.0.1', 'http', self.server.server_port + conf.path = '/test' + conf.httpHeaders = [('X-Auth-Token', 'expired')] + conf.parameters = {} + conf.data = None + conf.abortCode = [] + conf.ignoreCode = [] + conf.authRefresh = 'test_refresh.py' + conf.batch = True + conf.keepAlive = False + conf.retryOn = None + conf.timeout = 5 + conf.ignoreRedirects = False + conf.scope = None + kb.choices.redirect = REDIRECTION.YES + _urllib.request.urlopen = _urllib.request.build_opener(_urllib.request.ProxyHandler({}), SmartRedirectHandler()).open + + def refresh(): + # A real, application-specific token exchange outside sqlmap's request engine. + response = _urllib.request.urlopen(conf.url.replace('/test', '/refresh'), timeout=3) + try: + return {'X-Auth-Token': json.loads(response.read().decode('ascii'))['token']} + finally: + response.close() + + kb.authRefreshFunction = refresh + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(5) + _urllib.request.urlopen = self.urlopen + Connect.getPage, Connect.queryPage = self.methods + socket.setdefaulttimeout(self.timeout) + conf.clear() + conf.update(self.conf) + cmdLineOptions.clear() + cmdLineOptions.update(self.cmdLineOptions) + kb.clear() + kb.update(self.kb) + getCurrentThreadData().__dict__.clear() + getCurrentThreadData().__dict__.update(self.threadData) + + def test_expiry_mid_scan_preserves_payload_and_reuses_headers(self): + self.server.token = 'expired' + self.assertEqual(Connect.getPage()[2], 200) + self.assertEqual(self.server.refreshes, 0) + self.server.token = 'fresh' + payload = b'id=1%20AND%201%3D1&other=keep' + self.assertEqual(Connect.getPage(get='marker=123', post=payload)[2], 200) + self.assertEqual(self.server.requests[-2:], [('/test?marker=123', payload, 'expired'), ('/test?marker=123', payload, 'fresh')]) + self.assertEqual(Connect.getPage()[2], 200) + self.assertEqual(self.server.refreshes, 1) + self.assertEqual(getCurrentThreadData().lastCode, 200) + self.assertEqual(getCurrentThreadData().lastPage, 'ok') + self.assertNotEqual(kb.originalCode, 401) + + def test_second_401_stops_after_one_replay(self): + kb.authRefreshFunction = lambda: {'X-Auth-Token': 'still-expired'} + with self.assertRaises(SqlmapConnectionException): + Connect.getPage() + self.assertEqual(len(self.server.requests), 2) + self.assertTrue(kb.authRefreshFailed) + + def test_abort_code_takes_precedence(self): + conf.abortCode = [401] + with self.assertRaises(SystemExit): + Connect.getPage() + self.assertEqual(self.server.refreshes, 0) + self.assertEqual(len(self.server.requests), 1) + + def test_without_option_preserves_401_behavior(self): + conf.authRefresh = None + with self.assertRaises(SqlmapConnectionException): + Connect.getPage() + self.assertEqual(self.server.refreshes, 0) + + def test_concurrent_401s_share_one_refresh(self): + self.server.syncExpired = True + results, errors = [], [] + + def worker(): + try: + results.append(Connect.getPage()[2]) + except Exception as ex: + errors.append(ex) + + workers = [threading.Thread(target=worker) for _ in range(2)] + for workerThread in workers: + workerThread.start() + for workerThread in workers: + workerThread.join(10) + self.assertFalse(workerThread.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(results, [200, 200]) + self.assertEqual(self.server.refreshes, 1) + self.assertEqual(self.server.expired, 2) + + def test_invalid_hook_results_are_rejected_without_leaking_secrets(self): + for result in (None, {}, 'secret-token', {'X-Token': 'secret\r\nInjected: value'}, {'Host': 'elsewhere'}, {'X-Token': 42}, {'X-Token': 'secret', 'x-token': 'other'}): + kb.authRefreshFailed = False + kb.authRefreshFunction = lambda: result + with self.assertRaises(SqlmapConnectionException) as caught: + authrefresh.refresh(0) + self.assertNotIn('secret', str(caught.exception)) + _assertTemplate(self, caught.exception) + self.assertEqual(kb.authRefreshVersion, 0) + + def test_hook_exception_is_sanitized(self): + def refresh(): + raise ValueError('password=secret') + kb.authRefreshFunction = refresh + with self.assertRaises(SqlmapConnectionException) as caught: + Connect.getPage() + self.assertNotIn('secret', str(caught.exception)) + _assertTemplate(self, caught.exception) + self.assertEqual(len(self.server.requests), 1) + + def test_credentials_are_scoped_to_scheme_host_and_port(self): + authrefresh.refresh(0) + for url in ('http://elsewhere/test', 'https://127.0.0.1/test', 'http://127.0.0.1:1/test'): + req = _urllib.request.Request(url) + self.assertIsNone(authrefresh.apply(req)) + self.assertEqual(req.headers, {}) + self.assertTrue(authrefresh.sameOrigin('https://example.com', 'https://example.com:443/path')) + + def test_refresh_overrides_stale_preprocess_headers(self): + kb.preprocessFunctions = [lambda req: req.add_header('X-Auth-Token', 'expired')] + self.assertEqual(Connect.getPage()[2], 200) + self.assertEqual(self.server.refreshes, 1) + + def test_redirect_to_another_port_does_not_receive_refreshed_token(self): + authrefresh.refresh(0) + other = _Server(('127.0.0.1', 0), _Handler) + other.requests, other.token, other.expired = [], None, 0 + other.lock = threading.Lock() + other.syncExpired = False + thread = threading.Thread(target=other.serve_forever) + thread.daemon = True + thread.start() + try: + self.server.redirect = 'http://127.0.0.1:%d/landing' % other.server_port + Connect.getPage(url=conf.url.replace('/test', '/redirect')) + self.assertEqual(other.requests, [('/landing', b'', None)]) + self.assertEqual(self.server.refreshes, 1) + finally: + other.shutdown() + other.server_close() + thread.join(5) + + def test_auth_refresh_latency_is_excluded_from_query_timing(self): + import lib.request.connect as connect + originalPage, originalDelta = Connect.getPage, connect.calculateDeltaSeconds + + def getPage(**kwargs): + getCurrentThreadData().authRefreshTime += 12 + return 'ok', None, 200 + + Connect.getPage = staticmethod(getPage) + connect.calculateDeltaSeconds = lambda start: 15 + try: + Connect.queryPage(content=True) + self.assertEqual(getCurrentThreadData().lastQueryDuration, 3) + finally: + Connect.getPage = staticmethod(originalPage) + connect.calculateDeltaSeconds = originalDelta + + +class TestAuthRefreshLoader(unittest.TestCase): + def setUp(self): + self.directory = tempfile.mkdtemp(prefix='sqlmap-authrefresh-') + self.oldOption = conf.authRefresh + self.oldFunction = kb.authRefreshFunction + self.oldPath = list(sys.path) + + def tearDown(self): + conf.authRefresh = self.oldOption + kb.authRefreshFunction = self.oldFunction + sys.path[:] = self.oldPath + for name in list(sys.modules): + if name.startswith('authrefresh_test_'): + del sys.modules[name] + shutil.rmtree(self.directory) + + def script(self, name, content): + path = os.path.join(self.directory, 'authrefresh_test_%s.py' % name) + with open(path, 'w') as stream: + stream.write(content) + conf.authRefresh = path + + def test_load_does_not_call_refresh(self): + self.script('valid', "def refresh():\n raise AssertionError('must only run on 401')\n") + _setAuthRefreshFunction() + self.assertTrue(callable(kb.authRefreshFunction)) + + def test_missing_invalid_and_empty_scripts_point_to_template(self): + for name, source in (('empty', ''), ('wrong', 'def other():\n pass\n'), ('args', 'def refresh(req):\n pass\n'), ('syntax', 'def refresh(:')): + self.script(name, source) + with self.assertRaises(SqlmapSyntaxException) as caught: + _setAuthRefreshFunction() + _assertTemplate(self, caught.exception) + conf.authRefresh = os.path.join(self.directory, 'missing.py') + with self.assertRaises(SqlmapFilePathException) as caught: + _setAuthRefreshFunction() + _assertTemplate(self, caught.exception) + + def test_help_is_one_line(self): + output = subprocess.check_output([sys.executable, os.path.join(paths.SQLMAP_ROOT_PATH, 'sqlmap.py'), '-hh']) + lines = output.decode('utf-8').splitlines() + matches = [i for i, line in enumerate(lines) if '--auth-refresh' in line] + self.assertEqual(len(matches), 1) + self.assertIn('HTTP 401', lines[matches[0]]) + self.assertLessEqual(len(lines[matches[0]]), 78) + for option in ('--preprocess', '--postprocess'): + self.assertLess(matches[0], next(i for i, line in enumerate(lines) if option in line)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_configfile.py b/tests/test_configfile.py index 1fbe8a782..5ea9a94fb 100644 --- a/tests/test_configfile.py +++ b/tests/test_configfile.py @@ -114,16 +114,19 @@ class TestConfigFileRoundTrip(unittest.TestCase): conf.url = "http://127.0.0.1:1/?id=1" conf.commonTables = True conf.getLinks = True + conf.authRefresh = "refresh_auth.py" conf.tablePrefix = "zzz" saveConfig(conf, path) conf.url, conf.commonTables, conf.tablePrefix = None, False, "overwritten" conf.getLinks = False + conf.authRefresh = None configFileParser(path) self.assertEqual(conf.url, "http://127.0.0.1:1/?id=1") self.assertTrue(conf.commonTables) self.assertTrue(conf.getLinks) + self.assertEqual(conf.authRefresh, "refresh_auth.py") self.assertEqual(conf.tablePrefix, "zzz") finally: conf.clear() diff --git a/tests/test_http2.py b/tests/test_http2.py index 7ac0b2255..9a2c77309 100644 --- a/tests/test_http2.py +++ b/tests/test_http2.py @@ -699,6 +699,17 @@ class TestRedirectHandling(unittest.TestCase): self.assertEqual(calls[0][1], 8443) self.assertEqual(calls[0][4], "xn--tst-qla.example:8443") + def test_cross_origin_redirect_strips_custom_auth_headers(self): + responses = [(307, [(b"location", b"https://other.test/final")], b""), (200, [], b"ok")] + _, calls = self._run_with_fake_exchange( + responses, url="https://example.test/start", + headers={"X-Auth-Token": "secret", "X-Keep": "yes"}, + sensitive_headers=("X-Auth-Token",), + ) + names = [key.lower() for key, _ in calls[1][5]] + self.assertNotIn("x-auth-token", names) + self.assertIn("x-keep", names) + class TestPoolRetries(unittest.TestCase): class _Connection(object):