Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ WebUI/API Manual usage explained in the [Usage](Usage#api-and-webui) page but le

- [Purpose](#purpose)
- [Requests Structure](#requests-structure)
- [Upload File](#upload-file)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Sign the PR commit before merge

The reviewed commit 9d1383ddce47c4bc77a88ed59b1cd3e24fd59746 is unsigned (git log -1 --format='%G?' reports N), but this repository requires all commits to be signed; please rewrite or squash the PR commit with signing enabled before merge.

Useful? React with 👍 / 👎.

- [New Scan](#new-scan)
- [Set Session](#set-session)
* [Set Cookie](#set-cookie)
Expand Down Expand Up @@ -70,6 +71,37 @@ At the first, you must send an API key through the request each time you send a
401
```

## Upload File

The parameters that accept a file path (`targets_list`, `usernames_list`, `passwords_list`, and `read_from_file`) cannot be set directly through `/new/scan`; the API strips them from the form values and only honours paths populated by a prior upload. To provide a file for one of these parameters, `POST` it to `/upload/file` first and the server will store it and bind it to the named parameter for subsequent scans.

The request must be `multipart/form-data` with two **required** fields:

- `file` — the file contents
- `param_name` — one of `targets_list`, `usernames_list`, `passwords_list`, `read_from_file`

Constraints:

- Maximum size: 10 MB (`MAX_CONTENT_LENGTH`)
- Allowed extensions: `txt`, `csv`, `lst`, `list` (configurable via `allowed_upload_extensions` in `nettacker/config.py`). The check applies to every `param_name`.
- Filenames are sanitised and prefixed with a UUID before being written to the tmp directory.

On success the response `msg` is an **opaque signed token**, not a filename. The token is bound to the `param_name` it was uploaded for and **expires 15 minutes** after upload. Pass it back through `/new/scan` under the matching parameter; the server verifies the signature, the expiry, and that the token's parameter matches the field it is submitted under. Tokens are stateless (nothing is stored server-side beyond the temp file), so they do not survive an API restart. Temp files left by uploads that are never submitted are swept once their token expires.

```python
>>> r = requests.post(
... "https://127.0.0.1:5000/upload/file",
... data={"key": "<your_api_key>", "param_name": "targets_list"},
... files={"file": open("targets.txt", "rb")},
... verify=False,
... )
>>> r.json()
{"msg": "<signed_token>", "status": "ok"}
>>> # then, in POST /new/scan: targets_list=<signed_token>
```

Possible error responses (HTTP 400): `upload_invalid_param`, `upload_no_file`, `upload_no_file_selected`, `upload_invalid_filename`, `upload_file_type_not_allowed`. At `/new/scan`, a bad token yields `upload_token_invalid` (forged/expired/wrong parameter) or `upload_file_not_found` (already consumed or swept).

## New Scan

To submit a new scan follow this step.
Expand Down Expand Up @@ -217,7 +249,7 @@ u'{"msg":"please choose your scan method!","status":"error"}\n'

```

All variables in JSON you've got in results could be changed in `GET`/`POST`/`Cookies`, you can fill them all just like normal CLI commands. (e.g. same scan method name (modules), you can separate with `,`, you can use `ports` like `80,100-200,1000,2000`, set users and passwords `user1,user2`, `passwd1,passwd2`). You cannot use `read_from_file:/tmp/users.txt` syntax in `methods_args`. if you want to send a big password list, just send it through the `POST` requests and separated with `,`.
All variables in JSON you've got in results could be changed in `GET`/`POST`/`Cookies`, you can fill them all just like normal CLI commands. (e.g. same scan method name (modules), you can separate with `,`, you can use `ports` like `80,100-200,1000,2000`, set users and passwords `user1,user2`, `passwd1,passwd2`). You cannot use `read_from_file:/tmp/users.txt` syntax in `methods_args`. If you want to send a big password list, either pass it inline through `POST` separated with `,` or upload it via [`/upload/file`](#upload-file) with `param_name=passwords_list` (the same applies to `targets_list`, `usernames_list`, and `read_from_file` — passing them as plain form values is ignored because the server only trusts paths produced by an upload).

## Set Session

Expand Down
102 changes: 101 additions & 1 deletion nettacker/api/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
import multiprocessing
import os
import random
import re
import secrets
import string
import time
import uuid
from threading import Thread
from types import SimpleNamespace

from flask import Flask, Response, abort, jsonify, make_response, render_template
from flask import request as flask_request
from itsdangerous import BadData, URLSafeTimedSerializer
from werkzeug.serving import WSGIRequestHandler
from werkzeug.utils import secure_filename

Expand Down Expand Up @@ -50,6 +54,20 @@

app = Flask(__name__, template_folder=str(Config.path.web_static_dir))
app.config.from_object(__name__)
app.config["MAX_CONTENT_LENGTH"] = (
10 * 1024 * 1024
) # https://flask.palletsprojects.com/en/stable/patterns/fileuploads/
FILE_UPLOAD_PARAMS = ("targets_list", "passwords_list", "usernames_list", "read_from_file")
# Uploaded files are referenced by a signed, self-expiring token instead of a raw
# filename, so /new/scan only trusts paths that /upload/file actually issued (for the
# matching parameter) and never has to keep any server-side upload state.
UPLOAD_TOKEN_TTL_SECONDS = 15 * 60
_upload_token_serializer = URLSafeTimedSerializer(
secrets.token_hex(32), salt="nettacker-file-upload"
)
# Only files written by upload_file() match this prefix (uuid4 hex + "_"); the cleanup
# sweep is scoped to it so it can never touch reports or other tmp artifacts.
UPLOAD_FILENAME_RE = re.compile(r"^[0-9a-f]{32}_")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

nettacker_path_config = Config.path
nettacker_application_config = Config.settings.as_dict()
Expand Down Expand Up @@ -236,6 +254,62 @@ def sanitize_report_path_filename(report_path_filename):
return safe_report_path


def allowed_file(filename):
return (
"." in filename
and filename.rsplit(".", 1)[1].lower() in Config.settings.allowed_upload_extensions
)


def cleanup_expired_uploads():
"""Delete upload temp files whose token has expired.

Files that were uploaded but never submitted to a scan are otherwise never
cleaned up. The sweep is opportunistic (run on every upload) so it needs no
background task or shared state, and is scoped to the upload naming prefix so
it can never remove reports or other tmp artifacts.
"""
tmp_dir = nettacker_path_config.tmp_dir
if not tmp_dir.is_dir():
return
cutoff = time.time() - UPLOAD_TOKEN_TTL_SECONDS
for entry in tmp_dir.iterdir():
try:
if (
entry.is_file()
and UPLOAD_FILENAME_RE.match(entry.name)
and entry.stat().st_mtime < cutoff
):
entry.unlink(missing_ok=True)
except OSError:
continue


@app.route("/upload/file", methods=["POST"])
def upload_file():
api_key_is_valid(app, flask_request)
cleanup_expired_uploads()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Sweep abandoned upload files without another upload

Because cleanup only runs at the start of /upload/file, files from uploads that are never submitted, or superseded by selecting another file, are not removed when their 15-minute token expires unless a later upload request happens to trigger this call. The last batch of abandoned files can therefore remain in the tmp directory indefinitely, up to 10 MB each; run the sweep from a periodic/background path or delete superseded uploads explicitly.

Useful? React with 👍 / 👎.

param_name = flask_request.form.get("param_name", "")
if param_name not in FILE_UPLOAD_PARAMS:
return jsonify(structure(status="error", msg=_("upload_invalid_param"))), 400
if "file" not in flask_request.files:
return jsonify(structure(status="error", msg=_("upload_no_file"))), 400
uploaded = flask_request.files["file"]
if uploaded.filename == "":
return jsonify(structure(status="error", msg=_("upload_no_file_selected"))), 400
if not allowed_file(uploaded.filename):
return jsonify(structure(status="error", msg=_("upload_file_type_not_allowed"))), 400
filename = secure_filename(uploaded.filename)
if not filename:
return jsonify(structure(status="error", msg=_("upload_invalid_filename"))), 400
stored_name = f"{uuid.uuid4().hex}_{filename}"
tmp_dir = nettacker_path_config.tmp_dir
tmp_dir.mkdir(parents=True, exist_ok=True)
uploaded.save(str(tmp_dir / stored_name))
token = _upload_token_serializer.dumps({"param": param_name, "name": stored_name})
return jsonify(structure(status="ok", msg=token)), 200


@app.route("/new/scan", methods=["GET", "POST"])
def new_scan():
"""
Expand All @@ -253,6 +327,28 @@ def new_scan():
if not report_path_filename:
return jsonify(structure(status="error", msg="Invalid report filename")), 400
form_values["report_path_filename"] = str(report_path_filename)

uploaded_paths = []
for key in FILE_UPLOAD_PARAMS:
token = form_values.get(key)
if not token:
form_values.pop(key, None)
continue
try:
payload = _upload_token_serializer.loads(token, max_age=UPLOAD_TOKEN_TTL_SECONDS)
except BadData:
# tampered, forged, or expired token
return jsonify(structure(status="error", msg=_("upload_token_invalid"))), 400
if not isinstance(payload, dict) or payload.get("param") != key:
# token was issued for a different parameter
return jsonify(structure(status="error", msg=_("upload_token_invalid"))), 400
stored_name = secure_filename(payload.get("name", ""))
file_path = nettacker_path_config.tmp_dir / stored_name
if not stored_name or not file_path.is_file():
return jsonify(structure(status="error", msg=_("upload_file_not_found"))), 400
form_values[key] = str(file_path)
uploaded_paths.append(file_path)

for key in nettacker_application_config:
if key not in form_values:
form_values[key] = nettacker_application_config[key]
Expand All @@ -263,7 +359,11 @@ def new_scan():
]
# Handle service discovery
form_values["skip_service_discovery"] = form_values.get("skip_service_discovery", "") == "true"
nettacker_app = Nettacker(api_arguments=SimpleNamespace(**form_values))
try:
nettacker_app = Nettacker(api_arguments=SimpleNamespace(**form_values))
finally:
for file_path in uploaded_paths:
file_path.unlink(missing_ok=True)
Comment on lines +351 to +352

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep uploaded wordlists available during scans

When the uploaded token is for read_from_file, the scanner has not consumed the file by the time this finally runs: initialization only validates the path, while module templates are expanded in the background thread started below. Unlinking every uploaded path here removes the wordlist before the fuzzer can read it, so web/API scans that use the new Wordlist upload fail once module execution begins; defer deleting read_from_file uploads until the scan has finished or load the contents before unlinking.

Useful? React with 👍 / 👎.

app.config["OWASP_NETTACKER_CONFIG"]["options"] = nettacker_app.arguments
thread = Thread(target=nettacker_app.run)
thread.start()
Expand Down
1 change: 1 addition & 0 deletions nettacker/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ class DefaultSettings(ConfigBase):
verbose_mode = False
scan_compare_id = None
compare_report_path_filename = ""
allowed_upload_extensions = ("txt", "csv", "lst", "list")


class Config:
Expand Down
9 changes: 8 additions & 1 deletion nettacker/locale/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,11 @@ report_retrieval_fail: Could not retrieve the report
read_report_fail: Failed to read report file {0}
search_results_end: No more search results
database_error: Database error!
error_exclude_all: "Cannot exclude all modules. Please specify individual module names to exclude using -x."
error_exclude_all: "Cannot exclude all modules. Please specify individual module names to exclude using -x."
upload_no_file: no file provided
upload_no_file_selected: no file selected
upload_invalid_filename: invalid filename
upload_file_type_not_allowed: file type not allowed
upload_invalid_param: invalid or missing upload parameter
upload_token_invalid: invalid or expired upload token
upload_file_not_found: uploaded file not found or expired
24 changes: 23 additions & 1 deletion nettacker/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ <h3>Targets</h3>
<span class="input-group-addon"><i class="fa fa-space-shuttle"></i></span>
<input id="targets" type="text" class="form-control" data-role="tagsinput" placeholder="Add new target" >
</div>
<label class="btn btn-default btn-sm" style="margin-top: 5px;">
Upload targets file <input type="file" id="targets_list_file" style="display:none;">
</label>
<span id="targets_list_status"></span>
<div class="form-group" style="margin-top: 10px;">
<label>
<input id="skip_service_discovery" type="checkbox">
Expand Down Expand Up @@ -313,21 +317,39 @@ <h3>HTTP Headers</h3>
</div>
<br>


<div class="form-group col-md-6">
<h3>Usernames</h3>
<textarea class="input-mini form-control" rows="8" cols="50" id="usernames"
data-role="tagsinput" placeholder="separate with commas or new line">
</textarea>
<label class="btn btn-default btn-sm" style="margin-top: 5px;">
Upload usernames file <input type="file" id="usernames_list_file" style="display:none;" >
</label>
<span id="usernames_list_status"></span>
</div>
<div class="form-group col-md-6">
<h3>Passwords</h3>
<textarea class="form-control" rows="8" cols="50" id="passwords"
placeholder="separate with commas or new line"></textarea>
<label class="btn btn-default btn-sm" style="margin-top: 5px;">
Upload passwords file <input type="file" id="passwords_list_file" style="display:none;" >
</label>
<span id="passwords_list_status"></span>
</div>
<div class="clearfix"></div>
<div class="form-group col-md-6">
<h3>Wordlist</h3>
<label class="btn btn-default btn-sm">
Upload wordlist file <input type="file" id="read_from_file_file" style="display:none;"> <!-- Not checking for file extensions here because that is a config in the core -->
</label>
<span id="read_from_file_status"></span>
</div>
<br>
</div>
<div class="clearfix"></div>
<br>
<ul id="submit_new_scan" class="btn btn-primary" action="javascript:submit_new_scan()">
<ul id="submit_new_scan" class="btn btn-primary" style="margin-left: 15px;" action="javascript:submit_new_scan()">
Submit
</ul>
</form>
Expand Down
53 changes: 53 additions & 0 deletions nettacker/web/static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,52 @@ $(document).ready(function () {
}
});

// file upload handler
var uploaded_tokens = {};
var pendingUploads = 0;
$.each(["targets_list", "usernames_list", "passwords_list", "read_from_file"], function(_, param) {
$("#" + param + "_file").change(function () {
var fileInput = this;
if (!fileInput.files.length) return;
var formData = new FormData();
formData.append("file", fileInput.files[0]);
formData.append("param_name", param);
var statusSpan = $("#" + param + "_status");
statusSpan.text("Uploading...");
pendingUploads++;
$.ajax({
type: "POST",
url: "/upload/file",
data: formData,
processData: false,
contentType: false,
})
.done(function (res) {
uploaded_tokens[param] = res.msg;
statusSpan.text("Uploaded: " + fileInput.files[0].name);
})
.fail(function (jqXHR) {
delete uploaded_tokens[param];
statusSpan.text("Upload failed");
fileInput.value = "";
})
.always(function () {
pendingUploads--;
});
});
});

// submit new scan
$("#submit_new_scan").click(function () {
// block submission while file uploads are still in flight, otherwise the
// scan is submitted without the uploaded file tokens and silently misses them
if (pendingUploads > 0) {
document.getElementById("error_msg").innerHTML =
"Please wait for file uploads to finish before submitting.";
$("#failed_request").removeClass("hidden");
setTimeout('$("#failed_request").addClass("hidden");', 5000);
return;
}
// set variables
// check ranges
if (document.getElementById("scan_ip_range").checked) {
Expand Down Expand Up @@ -417,12 +461,21 @@ $(document).ready(function () {
}
}

$.extend(data, uploaded_tokens);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drop inline usernames when a username list is uploaded

For the new usernames upload, this adds usernames_list without removing the usernames textarea value. That textarea contains whitespace by default in the page markup, and the core parser checks if options.usernames before elif options.usernames_list, so an apparently empty upload-only submission ignores the uploaded file and uses the whitespace value instead; trim/drop usernames whenever a username-list token is present.

Useful? React with 👍 / 👎.


$.ajax({
type: "POST",
url: "/new/scan",
data: data,
})
.done(function (res) {
// the server deletes the uploaded temp files once the scan starts, so
// clear the consumed tokens to avoid re-sending stale ones on the next scan
uploaded_tokens = {};
$.each(["targets_list", "usernames_list", "passwords_list", "read_from_file"], function (_, param) {
$("#" + param + "_status").text("");
$("#" + param + "_file").val("");
});
var results = JSON.stringify(res);
results = results.replaceAll(",", ",<br>");
document.getElementById("success_msg").innerHTML = results;
Expand Down
Loading