Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
18 changes: 18 additions & 0 deletions scripts/apl-feed/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,24 @@ _config_sync_translate_response() {
local api_field is_null value edited_at edited_by
while IFS=$'\t' read -r api_field is_null value edited_at edited_by; do
[[ -z "$api_field" ]] && continue
# The server-side serializer enforces this allowlist on inbound
# writes; mirror it on the response so a misbehaving server cannot
# smuggle an unknown actor label into feed.meta.json. Drop the
# field entirely (no out_args entry, no incoming-meta entry) and
# continue — other fields in the same response still apply.
case "$edited_by" in
feeder|website|legacy) ;;
*)
# Quote the value so an attacker-controlled string can't
# forge extra key=value pairs in the structured log line.
# jq @tsv already escapes tabs/newlines; this guards
# against spaces and embedded `=`.
local _bad_value="${edited_by:0:64}"
_bad_value="${_bad_value//\"/\\\"}"
_config_sync_log warn "reason=bad_edited_by field=$api_field value=\"$_bad_value\""
continue
;;
esac
case "$api_field" in
position)
if [[ "$is_null" == "1" ]]; then
Expand Down
11 changes: 9 additions & 2 deletions scripts/apl-feed/http.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
#!/usr/bin/env bash

# Caps the response at 128 KiB via curl --max-filesize so a misbehaving
# server cannot fill /tmp on a disk-constrained feeder. The well-formed
# responses for the known endpoints (/status, /config/sync, /diagnostics)
# are all well under 1 KiB; the cap is loose-fitting.
post_json() {
local path="$1"
local body="$2"
local response_file="$3"
printf '%s' "$body" | curl --silent --show-error \
--connect-timeout 10 --max-time 30 \
--connect-timeout 10 --max-time 30 --max-filesize 131072 \
--request POST \
--header 'Content-Type: application/json' \
--data-binary @- \
Expand All @@ -22,6 +26,9 @@ post_json() {
# expose). The tempfile is registered in TMP_FILES so common.sh's EXIT
# trap removes it when the caller exits.
#
# Caps the response at 128 KiB via curl --max-filesize so a misbehaving
# server cannot fill /tmp.
#
# Returns curl's exit code; echoes the HTTP status to stdout (or empty
# on transport failure).
post_json_bearer() {
Expand Down Expand Up @@ -57,7 +64,7 @@ post_json_bearer() {
fi

printf '%s' "$body" | curl --silent --show-error \
--connect-timeout 10 --max-time 30 \
--connect-timeout 10 --max-time 30 --max-filesize 131072 \
--request POST \
--header 'Content-Type: application/json' \
--config "$cfg" \
Expand Down
109 changes: 109 additions & 0 deletions test/test_apl_feed_config_sync.bats
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,115 @@ EOF
[ "$(jq -r '.fields.mlat_user.edited_at' <<<"$SYNC_OUT")" = "2020-01-01T00:00:00Z" ]
}

@test "response edited_by={feeder,website,legacy} all apply normally" {
# Sanity: each accepted actor label produces a normal apply.
seed_feed_env
seed_feed_meta MLAT_USER "2026-05-10T00:00:00Z" \
ALTITUDE "2026-05-10T00:00:00Z" \
LATITUDE "2026-05-10T00:00:00Z" \
LONGITUDE "2026-05-10T00:00:00Z"
set_canned 200 '{
"schema_version": 1,
"server_time": "2026-05-14T12:00:00Z",
"owned": true,
"fields": {
"position": {"value": {"lat": 47.0, "lon": 8.0}, "edited_at": "2026-05-14T11:00:00Z", "edited_by": "legacy"},
"alt": {"value": "200m", "edited_at": "2026-05-14T11:00:00Z", "edited_by": "feeder"},
"mlat_user": {"value": "bob", "edited_at": "2026-05-14T11:00:00Z", "edited_by": "website"},
"mlat_enabled": {"value": true, "edited_at": "2026-05-14T11:00:00Z", "edited_by": "feeder"},
"mlat_private": {"value": false, "edited_at": "2026-05-14T11:00:00Z", "edited_by": "feeder"}
}
}'

run_sync --no-restart

[ "$SYNC_RC" -eq 0 ]
grep -F 'MLAT_USER="bob"' "$ROOT_DIR/etc/airplanes/feed.env"
grep -F 'ALTITUDE="200m"' "$ROOT_DIR/etc/airplanes/feed.env"
[ "$(jq -r '.fields.MLAT_USER.edited_by' "$ROOT_DIR/etc/airplanes/feed.meta.json")" = "website" ]
[ "$(jq -r '.fields.ALTITUDE.edited_by' "$ROOT_DIR/etc/airplanes/feed.meta.json")" = "feeder" ]
[ "$(jq -r '.fields.LATITUDE.edited_by' "$ROOT_DIR/etc/airplanes/feed.meta.json")" = "legacy" ]
}

@test "response edited_by outside allowlist drops only the offending field" {
# Server response carries a bogus actor label on mlat_user. That
# field must be dropped (logged with reason=bad_edited_by), but the
# other fields in the same response must still apply.
seed_feed_env
seed_feed_meta MLAT_USER "2026-05-10T00:00:00Z" \
ALTITUDE "2026-05-10T00:00:00Z"
set_canned 200 '{
"schema_version": 1,
"server_time": "2026-05-14T12:00:00Z",
"owned": true,
"fields": {
"alt": {"value": "200m", "edited_at": "2026-05-14T11:00:00Z", "edited_by": "feeder"},
"mlat_user": {"value": "mallory", "edited_at": "2026-05-14T11:00:00Z", "edited_by": "attacker"}
}
}'

run_sync --no-restart

[ "$SYNC_RC" -eq 0 ]
echo "$SYNC_ERR" | grep -F 'reason=bad_edited_by'
echo "$SYNC_ERR" | grep -F 'field=mlat_user'
# mlat_user value on disk is untouched.
grep -F 'MLAT_USER="alice"' "$ROOT_DIR/etc/airplanes/feed.env"
# Sidecar entry for MLAT_USER is unchanged (still feeder + the seeded stamp).
[ "$(jq -r '.fields.MLAT_USER.edited_by' "$ROOT_DIR/etc/airplanes/feed.meta.json")" = "feeder" ]
[ "$(jq -r '.fields.MLAT_USER.edited_at' "$ROOT_DIR/etc/airplanes/feed.meta.json")" = "2026-05-10T00:00:00Z" ]
# The well-formed field still applied.
grep -F 'ALTITUDE="200m"' "$ROOT_DIR/etc/airplanes/feed.env"
}

@test "response edited_by with embedded space/equals is logged as a quoted token" {
# An attacker-controlled value must not forge extra key=value pairs in
# the structured log line. The dropped-field log entry quotes the value.
seed_feed_env
set_canned 200 '{
"schema_version": 1,
"server_time": "2026-05-14T12:00:00Z",
"owned": true,
"fields": {
"alt": {"value": "200m", "edited_at": "2026-05-14T11:00:00Z", "edited_by": "evil reason=spoofed"}
}
}'

run_sync --no-restart

[ "$SYNC_RC" -eq 0 ]
echo "$SYNC_ERR" | grep -F 'reason=bad_edited_by'
echo "$SYNC_ERR" | grep -F 'field=alt'
# The hostile value must appear quoted, so a downstream parser cannot
# mistake `reason=spoofed` for a new log key.
echo "$SYNC_ERR" | grep -F 'value="evil reason=spoofed"'
}

@test "response position edited_by outside allowlist drops both axes atomically" {
# The translator emits LATITUDE+LONGITUDE entries from a single
# `position` line — when that line is dropped for bad edited_by,
# neither axis must end up applied.
seed_feed_env
seed_feed_meta LATITUDE "2026-05-10T00:00:00Z" LONGITUDE "2026-05-10T00:00:00Z"
set_canned 200 '{
"schema_version": 1,
"server_time": "2026-05-14T12:00:00Z",
"owned": true,
"fields": {
"position": {"value": {"lat": 99.9, "lon": 99.9}, "edited_at": "2026-05-14T11:00:00Z", "edited_by": "rogue"}
}
}'

run_sync --no-restart

[ "$SYNC_RC" -eq 0 ]
echo "$SYNC_ERR" | grep -F 'reason=bad_edited_by'
echo "$SYNC_ERR" | grep -F 'field=position'
# On-disk position must be unchanged.
grep -F 'LATITUDE="47.0"' "$ROOT_DIR/etc/airplanes/feed.env"
grep -F 'LONGITUDE="8.0"' "$ROOT_DIR/etc/airplanes/feed.env"
}

@test "position group skips atomically when only one axis is newer locally" {
# Hand-divergent on-disk stamps: LATITUDE was edited locally just
# now, LONGITUDE is still on the 2020 legacy seed. Server returns a
Expand Down
76 changes: 76 additions & 0 deletions test/test_apl_feed_http.bats
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,79 @@ SH
[ "$output" = '400' ]
[ "$(cat "$response_file")" = '{"error":"bad_request"}' ]
}

# --- response-size cap ---
#
# Both POST helpers pass --max-filesize 131072 so a misbehaving server
# cannot fill /tmp on a disk-constrained feeder. curl announces the
# expected size via Content-Length; if that exceeds the cap, curl exits
# 63 before reading the body.

start_mock_server_big_body() {
# Emits an HTTP response with the given Content-Length and then
# streams that many bytes. Used to exercise the --max-filesize cap.
local status="$1"
local size="$2"
python3 - "$MOCK_PORT_FILE" "$status" "$size" <<'PY' &
import http.server, sys
port_file = sys.argv[1]
status = int(sys.argv[2])
size = int(sys.argv[3])
class H(http.server.BaseHTTPRequestHandler):
def do_POST(self):
_ = self.rfile.read(int(self.headers.get("Content-Length", 0)))
self.send_response(status)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(size))
self.end_headers()
# Stream bytes in chunks so the wire reflects the announced size.
chunk = b"A" * 4096
remaining = size
try:
while remaining > 0:
n = min(remaining, len(chunk))
self.wfile.write(chunk[:n])
remaining -= n
except BrokenPipeError:
pass
def log_message(self, *a, **kw): pass
s = http.server.HTTPServer(("127.0.0.1", 0), H)
with open(port_file, "w") as f:
f.write(str(s.server_address[1]))
s.serve_forever()
PY
echo $! > "$MOCK_PID_FILE"
for _ in 1 2 3 4 5 6 7 8 9 10; do
[[ -s "$MOCK_PORT_FILE" ]] && return 0
sleep 0.1
done
return 1
}

@test "post_json: response larger than 128 KiB cap exits with curl --max-filesize error" {
# 200 KiB body — Content-Length > 131072 so curl bails with exit 63
# (CURLE_FILESIZE_EXCEEDED) before writing anything to the response file.
start_mock_server_big_body 200 204800
SERVER_URL="$(mock_url)"
response_file="$TMPDIR/resp"
run post_json '/api/feeders/secret' '{"x":1}' "$response_file"
[ "$status" -eq 63 ]
}

@test "post_json_bearer: response larger than 128 KiB cap exits with curl --max-filesize error" {
start_mock_server_big_body 200 204800
SERVER_URL="$(mock_url)"
response_file="$TMPDIR/resp"
run post_json_bearer 'alv1.x.y' '/api/feeders/diagnostics' '{"x":1}' "$response_file"
[ "$status" -eq 63 ]
}

@test "post_json: response just under 128 KiB cap succeeds" {
# Sanity: 120 KiB body (well under the 128 KiB cap) round-trips fine.
start_mock_server_big_body 200 122880
SERVER_URL="$(mock_url)"
response_file="$TMPDIR/resp"
run post_json '/api/feeders/secret' '{"x":1}' "$response_file"
[ "$status" -eq 0 ]
[ "$output" = '200' ]
}