|
6 | 6 | """ |
7 | 7 |
|
8 | 8 | import doctest |
| 9 | +import json |
9 | 10 | import logging |
10 | 11 | import os |
11 | 12 | import random |
12 | 13 | import re |
13 | 14 | import socket |
14 | 15 | import sqlite3 |
| 16 | +import subprocess |
15 | 17 | import sys |
16 | 18 | import tempfile |
17 | 19 | import threading |
|
20 | 22 | from extra.vulnserver import vulnserver |
21 | 23 | from lib.core.common import clearConsoleLine |
22 | 24 | from lib.core.common import dataToStdout |
| 25 | +from lib.core.common import getSafeExString |
23 | 26 | from lib.core.common import randomInt |
24 | 27 | from lib.core.common import randomStr |
25 | 28 | from lib.core.common import shellExec |
26 | 29 | from lib.core.compat import round |
| 30 | +from lib.core.compat import xrange |
27 | 31 | from lib.core.convert import encodeBase64 |
| 32 | +from lib.core.convert import getBytes |
| 33 | +from lib.core.convert import getText |
28 | 34 | from lib.core.data import kb |
29 | 35 | from lib.core.data import logger |
30 | 36 | from lib.core.data import paths |
31 | 37 | from lib.core.data import queries |
32 | 38 | from lib.core.patch import unisonRandom |
33 | 39 | from lib.core.settings import IS_WIN |
| 40 | +from lib.core.settings import RESTAPI_VERSION |
34 | 41 |
|
35 | 42 | def vulnTest(): |
36 | 43 | """ |
@@ -224,6 +231,156 @@ def _thread(): |
224 | 231 |
|
225 | 232 | return retVal |
226 | 233 |
|
| 234 | +def apiTest(): |
| 235 | + """ |
| 236 | + Runs a basic live test of the REST API: launches the server in a separate process |
| 237 | + ('sqlmapapi.py -s') and drives the control-plane endpoints with an HTTP client - a real |
| 238 | + server + client round-trip, without launching an actual scan. A separate process (rather |
| 239 | + than an in-process thread) isolates the single-threaded server from the client's GIL and |
| 240 | + from sqlmap's global HTTP machinery, which otherwise makes the round-trip flaky. |
| 241 | + """ |
| 242 | + |
| 243 | + retVal = True |
| 244 | + |
| 245 | + # pick a free port the same way vulnTest() does |
| 246 | + while True: |
| 247 | + address, port = "127.0.0.1", random.randint(10000, 65535) |
| 248 | + try: |
| 249 | + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| 250 | + if s.connect_ex((address, port)): |
| 251 | + break |
| 252 | + else: |
| 253 | + time.sleep(1) |
| 254 | + finally: |
| 255 | + s.close() |
| 256 | + |
| 257 | + username, password = "test", "test" |
| 258 | + apipath = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "sqlmapapi.py")) |
| 259 | + |
| 260 | + try: |
| 261 | + devnull = subprocess.DEVNULL |
| 262 | + except AttributeError: |
| 263 | + devnull = open(os.devnull, "wb") |
| 264 | + |
| 265 | + process = subprocess.Popen([sys.executable, apipath, "-s", "-H", address, "-p", str(port), "--username", username, "--password", password], stdout=devnull, stderr=devnull) |
| 266 | + |
| 267 | + base = "http://%s:%d" % (address, port) |
| 268 | + |
| 269 | + def _call(path, data=None, authorize=True): |
| 270 | + # NOTE: a raw socket is used deliberately instead of urllib/http.client. The host sqlmap |
| 271 | + # process installs a global keep-alive opener and patches http.client, which makes a |
| 272 | + # library client flaky against the single-threaded server; a hand-rolled HTTP/1.0 request |
| 273 | + # (Connection: close, read to EOF) is hermetic and immune to all of that. |
| 274 | + method = "POST" if data is not None else "GET" |
| 275 | + lines = ["%s %s HTTP/1.0" % (method, path), "Host: %s:%d" % (address, port)] |
| 276 | + if authorize: |
| 277 | + lines.append("Authorization: Basic %s" % encodeBase64("%s:%s" % (username, password), binary=False)) |
| 278 | + body = getBytes(json.dumps(data)) if data is not None else b"" |
| 279 | + if data is not None: |
| 280 | + lines.append("Content-Type: application/json") |
| 281 | + lines.append("Content-Length: %d" % len(body)) |
| 282 | + lines.append("Connection: close") |
| 283 | + request = getBytes("\r\n".join(lines) + "\r\n\r\n") + body |
| 284 | + |
| 285 | + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| 286 | + s.settimeout(10) |
| 287 | + try: |
| 288 | + s.connect((address, port)) |
| 289 | + s.sendall(request) |
| 290 | + raw = b"" |
| 291 | + while True: |
| 292 | + chunk = s.recv(8192) |
| 293 | + if not chunk: |
| 294 | + break |
| 295 | + raw += chunk |
| 296 | + except Exception as ex: |
| 297 | + logger.debug("API test: request to '%s' failed (%s)" % (path, getSafeExString(ex))) |
| 298 | + return None, None |
| 299 | + finally: |
| 300 | + s.close() |
| 301 | + |
| 302 | + head, _, payload = raw.partition(b"\r\n\r\n") |
| 303 | + try: |
| 304 | + code = int(head.split(b"\r\n")[0].split(b" ")[1]) |
| 305 | + except (IndexError, ValueError): |
| 306 | + return None, None |
| 307 | + try: |
| 308 | + return code, json.loads(getText(payload)) |
| 309 | + except ValueError: |
| 310 | + return code, None |
| 311 | + |
| 312 | + try: |
| 313 | + # wait for the server process to come up (or die trying) |
| 314 | + for _ in xrange(200): |
| 315 | + if process.poll() is not None: |
| 316 | + logger.error("API test: server process exited prematurely (address: '%s')" % base) |
| 317 | + return False |
| 318 | + code, data = _call("/version") |
| 319 | + if code == 200 and data and data.get("success"): |
| 320 | + break |
| 321 | + time.sleep(0.1) |
| 322 | + else: |
| 323 | + logger.error("API test: server did not come up (address: '%s')" % base) |
| 324 | + return False |
| 325 | + |
| 326 | + logger.info("REST API server running at '%s'..." % base) |
| 327 | + |
| 328 | + results = [] |
| 329 | + |
| 330 | + def _check(name, condition): |
| 331 | + results.append((name, bool(condition))) |
| 332 | + if not condition: |
| 333 | + logger.error("API test: check '%s' FAILED" % name) |
| 334 | + |
| 335 | + # GET /version - success envelope + MAJOR-only integer api_version |
| 336 | + code, data = _call("/version") |
| 337 | + _check("version", code == 200 and data and data.get("success") is True and data.get("api_version") == int(RESTAPI_VERSION.split(".")[0]) and data.get("version")) |
| 338 | + |
| 339 | + # the auth hook must reject an unauthenticated request |
| 340 | + code, _ = _call("/version", authorize=False) |
| 341 | + _check("auth-401", code == 401) |
| 342 | + |
| 343 | + # GET /task/new - mint a task |
| 344 | + code, data = _call("/task/new") |
| 345 | + taskid = data.get("taskid") if data else None |
| 346 | + _check("task-new", code == 200 and data and data.get("success") and taskid) |
| 347 | + |
| 348 | + # POST /option/<taskid>/set then read it back via /get and /list (JSON round-trip + IPC) |
| 349 | + code, data = _call("/option/%s/set" % taskid, {"flushSession": True}) |
| 350 | + _check("option-set", code == 200 and data and data.get("success")) |
| 351 | + |
| 352 | + code, data = _call("/option/%s/get" % taskid, ["flushSession"]) |
| 353 | + _check("option-get", data and data.get("success") and (data.get("options") or {}).get("flushSession") is True) |
| 354 | + |
| 355 | + code, data = _call("/option/%s/list" % taskid) |
| 356 | + _check("option-list", data and data.get("success") and isinstance(data.get("options"), dict)) |
| 357 | + |
| 358 | + # GET /admin/list - the IP-bound listing (our client is the task's creator) must see it |
| 359 | + code, data = _call("/admin/list") |
| 360 | + _check("admin-list", data and data.get("success") and taskid in (data.get("tasks") or {})) |
| 361 | + |
| 362 | + # a bogus task ID must produce a failure envelope (not a crash) |
| 363 | + code, data = _call("/option/%s/list" % "nonexistent") |
| 364 | + _check("invalid-task", data is not None and data.get("success") is False) |
| 365 | + |
| 366 | + # GET /task/<taskid>/delete - tear the task down |
| 367 | + code, data = _call("/task/%s/delete" % taskid) |
| 368 | + _check("task-delete", data and data.get("success")) |
| 369 | + |
| 370 | + if all(ok for _, ok in results): |
| 371 | + logger.info("API test final result: PASSED") |
| 372 | + else: |
| 373 | + retVal = False |
| 374 | + logger.error("API test final result: FAILED (%s)" % ", ".join(name for name, ok in results if not ok)) |
| 375 | + finally: |
| 376 | + try: |
| 377 | + process.terminate() |
| 378 | + process.wait() |
| 379 | + except Exception: |
| 380 | + pass |
| 381 | + |
| 382 | + return retVal |
| 383 | + |
227 | 384 | def smokeTest(): |
228 | 385 | """ |
229 | 386 | Runs the basic smoke testing of a program |
|
0 commit comments