Skip to content

kmlgen: restrict HTTP server exposure - #1727

Merged
peterbarker merged 1 commit into
ArduPilot:masterfrom
jFriedli:fix/kmlgen-http-exposure
Aug 14, 2026
Merged

kmlgen: restrict HTTP server exposure#1727
peterbarker merged 1 commit into
ArduPilot:masterfrom
jFriedli:fix/kmlgen-http-exposure

Conversation

@jFriedli

Copy link
Copy Markdown
Contributor

Fixes #1726.

This changes the KMLGen HTTP server in two ways:

  • adds a configurable bind_address setting, defaulting to 127.0.0.1 instead of listening on all interfaces
  • restricts the HTTP handler to serving only the generated mission KML instead of the complete flight log directory

Other bind addresses can still be configured explicitly. For example:

kmlgen set bind_address 0.0.0.0

When a specific non-loopback address is configured, that address is also used in the generated NetworkLink URL. Loopback and wildcard binds continue to use localhost.

The file restriction resolves the requested path and compares its canonical path exactly with the generated mission KML path before allowing SimpleHTTPRequestHandler to serve it.

Testing

I reproduced #1726 before applying the patch and then ran the same full MAVProxy/KMLGen workflow against the patched code.

The patched instance produced:

MISSION_GET=200
MISSION_BYTES=1036
MISSION_HEAD=200
SECRET_GET=404
TLOG_GET=404

LISTENER:
LISTEN ... 127.0.0.1:<port> ... 0.0.0.0:* ...

WRAPPER_URL_OK=http://localhost:<port>/mission.kml

KMLGEN_1726_PATCH_CONFIRMED

This verifies that the generated mission KML is still served, while flight.tlog and an unrelated file in the same log directory are no longer accessible. It also verifies that the normal server binds to 127.0.0.1 by default.

Standalone full-application regression test
#!/usr/bin/env python3

import os
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path

from pymavlink import mavutil


repo = Path.cwd()
work = Path(tempfile.mkdtemp(prefix="kmlgen-fixed-"))


def free_port():
    sock = socket.socket()
    sock.bind(("127.0.0.1", 0))
    port = sock.getsockname()[1]
    sock.close()
    return port


master_port = free_port()
http_port = free_port()

vehicle = mavutil.mavlink_connection(
    f"tcpin:127.0.0.1:{master_port}",
    source_system=1,
    source_component=1,
    dialect="ardupilotmega",
)

mission = work / "mission.txt"
mission.write_text(
    "QGC WPL 110\n"
    "0\t1\t0\t16\t0\t0\t0\t0\t47.3769\t8.5417\t500\t1\n"
    "1\t0\t3\t16\t0\t0\t0\t0\t47.3770\t8.5418\t50\t1\n"
)

env = os.environ.copy()
env["PYTHONPATH"] = str(repo)
env["MAVLINK20"] = "1"

proc = subprocess.Popen(
    [
        sys.executable,
        str(repo / "MAVProxy" / "mavproxy.py"),
        f"--master=tcp:127.0.0.1:{master_port}",
        "--force-connected",
        "--mav20",
        "--source-system=255",
        "--source-component=230",
        "--target-system=1",
        "--target-component=1",
        "--default-modules=wp,kmlgen",
        f"--state-basedir={work}",
        "--aircraft=reportproof",
    ],
    cwd=work,
    env=env,
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,
    text=True,
)

try:
    vehicle.recv_match(blocking=True, timeout=5)

    vehicle.mav.heartbeat_send(
        mavutil.mavlink.MAV_TYPE_QUADROTOR,
        mavutil.mavlink.MAV_AUTOPILOT_ARDUPILOTMEGA,
        0,
        0,
        mavutil.mavlink.MAV_STATE_ACTIVE,
    )

    time.sleep(0.5)

    proc.stdin.write(
        f"wp show {mission}\n"
        f"kmlgen set port {http_port}\n"
        "kmlgen start\n"
    )
    proc.stdin.flush()

    networklink = None
    deadline = time.time() + 8

    while time.time() < deadline:
        found = list(work.rglob("networklink.kml"))
        if found:
            networklink = found[0]
            break
        time.sleep(0.1)

    if networklink is None:
        raise RuntimeError("networklink.kml was not created")

    logdir = networklink.parent

    # Add a non-KML file to the exact directory previously exposed
    # by SimpleHTTPRequestHandler.
    secret = logdir / "operator-notes.txt"
    secret.write_text("SHOULD_NOT_BE_EXPOSED\n")

    mission_url = f"http://127.0.0.1:{http_port}/mission.kml"

    mission_body = None
    deadline = time.time() + 8

    while time.time() < deadline:
        try:
            mission_body = urllib.request.urlopen(
                mission_url,
                timeout=0.5,
            ).read()
            break
        except Exception:
            time.sleep(0.1)

    if mission_body is None:
        raise RuntimeError("mission.kml was not retrievable")

    print("MISSION_GET=200")
    print("MISSION_BYTES=", len(mission_body))

    req = urllib.request.Request(
        mission_url,
        method="HEAD",
    )
    with urllib.request.urlopen(req, timeout=2) as response:
        print("MISSION_HEAD=", response.status)

    secret_url = (
        f"http://127.0.0.1:{http_port}/operator-notes.txt"
    )

    try:
        urllib.request.urlopen(secret_url, timeout=2).read()
        raise RuntimeError(
            "FAIL: arbitrary log-directory file was exposed"
        )
    except urllib.error.HTTPError as exc:
        print("SECRET_GET=", exc.code)
        assert exc.code == 404

    tlog = logdir / "flight.tlog"

    if tlog.exists():
        tlog_url = (
            f"http://127.0.0.1:{http_port}/flight.tlog"
        )

        try:
            urllib.request.urlopen(tlog_url, timeout=2).read()
            raise RuntimeError(
                "FAIL: flight.tlog was exposed"
            )
        except urllib.error.HTTPError as exc:
            print("TLOG_GET=", exc.code)
            assert exc.code == 404

    print("\nLISTENER:")
    subprocess.run(
        ["ss", "-ltnp", f"sport = :{http_port}"],
        check=False,
    )

    wrapper = networklink.read_text()
    expected = (
        f"http://localhost:{http_port}/mission.kml"
    )

    assert expected in wrapper
    print("WRAPPER_URL_OK=", expected)

    print("\nKMLGEN_1726_PATCH_CONFIRMED")

finally:
    proc.terminate()

    try:
        proc.wait(timeout=2)
    except subprocess.TimeoutExpired:
        proc.kill()

    vehicle.close()

Run:

PYTHONPATH=. python /tmp/test_kmlgen_fullapp_fixed.py

My patched run returned:

MISSION_GET=200
MISSION_BYTES= 1036
MISSION_HEAD= 200
SECRET_GET= 404
TLOG_GET= 404

LISTENER:
LISTEN ... 127.0.0.1:56417 ... 0.0.0.0:* ...

WRAPPER_URL_OK= http://localhost:56417/mission.kml

KMLGEN_1726_PATCH_CONFIRMED

I also ran focused regression checks for:

  • default 127.0.0.1 binding
  • explicit 0.0.0.0 binding
  • specific configured bind addresses
  • GET and HEAD for the generated mission KML
  • denial of flight.tlog, networklink.kml, and unrelated log-directory files
  • plain and URL-encoded traversal attempts
  • normal mission and NetworkLink generation
  • NetworkLink host generation

Repository checks:

scripts/run_flake8.py MAVProxy
python -m py_compile MAVProxy/modules/mavproxy_kmlgen.py

All passed.

@peterbarker peterbarker left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@peterbarker
peterbarker merged commit e4a5258 into ArduPilot:master Aug 14, 2026
2 checks passed
@peterbarker

Copy link
Copy Markdown
Contributor

Merged, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

KMLGen HTTP Server Exposes Flight Log Directory to Network Peers

2 participants