Move user credentials into HTTP request headers to prevent them being logged in the server logs

This commit is contained in:
Ian Renton
2026-06-20 10:15:35 +01:00
parent ae17839096
commit e08a183d1b
13 changed files with 58 additions and 77 deletions

View File

@@ -12,15 +12,15 @@ class LookupCredentials:
hamqth_session_id: str = "" # alternative to username/password hamqth_session_id: str = "" # alternative to username/password
def extract_credentials(query_params): def extract_credentials(headers):
"""Build a LookupCredentials from HTTP query params; returns None if no usable credentials are present.""" """Build a LookupCredentials from HTTP request headers; returns None if no usable credentials are present."""
creds = LookupCredentials( creds = LookupCredentials(
qrz_username=query_params.get("qrz_username", ""), qrz_username=headers.get("X-QRZ-Username", ""),
qrz_password=query_params.get("qrz_password", ""), qrz_password=headers.get("X-QRZ-Password", ""),
qrz_session_key=query_params.get("qrz_session_key", ""), qrz_session_key=headers.get("X-QRZ-Session-Key", ""),
hamqth_username=query_params.get("hamqth_username", ""), hamqth_username=headers.get("X-HamQTH-Username", ""),
hamqth_password=query_params.get("hamqth_password", ""), hamqth_password=headers.get("X-HamQTH-Password", ""),
hamqth_session_id=query_params.get("hamqth_session_id", ""), hamqth_session_id=headers.get("X-HamQTH-Session-ID", ""),
) )
has_qrz = creds.qrz_session_key or (creds.qrz_username and creds.qrz_password) has_qrz = creds.qrz_session_key or (creds.qrz_username and creds.qrz_password)
has_hamqth = creds.hamqth_session_id or (creds.hamqth_username and creds.hamqth_password) has_hamqth = creds.hamqth_session_id or (creds.hamqth_username and creds.hamqth_password)

View File

@@ -53,7 +53,7 @@ class APIAlertsHandler(tornado.web.RequestHandler):
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Fetch all alerts matching the query, then optionally enrich with online data # Fetch all alerts matching the query, then optionally enrich with online data
credentials = extract_credentials(query_params) credentials = extract_credentials(self.request.headers)
data = get_alert_list_with_filters(self._alerts, query_params) data = get_alert_list_with_filters(self._alerts, query_params)
if credentials: if credentials:
data = self._enrich(data, credentials) data = self._enrich(data, credentials)
@@ -104,7 +104,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# request.arguments contains lists for each param key because technically the client can supply multiple, # request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string # reduce that to just the first entry, and convert bytes to string
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
self._credentials = extract_credentials(self._query_params) self._credentials = extract_credentials(self.request.headers)
# Create a alert queue and add it to the web server's list. The web server will fill this when alerts arrive # Create a alert queue and add it to the web server's list. The web server will fill this when alerts arrive
self._alert_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE) self._alert_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)

View File

@@ -47,7 +47,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
if re.match(r"^[A-Z0-9/\-]*$", call): if re.match(r"^[A-Z0-9/\-]*$", call):
# Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the # Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the
# resulting data in the correct way for the API response. # resulting data in the correct way for the API response.
credentials = extract_credentials(query_params) credentials = extract_credentials(self.request.headers)
fake_spot = Spot(dx_call=call) fake_spot = Spot(dx_call=call)
fake_spot.infer_missing(credentials) fake_spot.infer_missing(credentials)
data = { data = {

View File

@@ -53,7 +53,7 @@ class APISpotsHandler(tornado.web.RequestHandler):
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Fetch all spots matching the query, then optionally enrich with online data # Fetch all spots matching the query, then optionally enrich with online data
credentials = extract_credentials(query_params) credentials = extract_credentials(self.request.headers)
data = get_spot_list_with_filters(self._spots, query_params) data = get_spot_list_with_filters(self._spots, query_params)
if credentials: if credentials:
data = self._enrich(data, credentials) data = self._enrich(data, credentials)
@@ -106,7 +106,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# request.arguments contains lists for each param key because technically the client can supply multiple, # request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string # reduce that to just the first entry, and convert bytes to string
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
self._credentials = extract_credentials(self._query_params) self._credentials = extract_credentials(self.request.headers)
# Create a spot queue and add it to the web server's list. The web server will fill this when spots arrive # Create a spot queue and add it to the web server's list. The web server will fill this when spots arrive
self._spot_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE) self._spot_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)

View File

@@ -75,10 +75,7 @@
</div> </div>
<script> <script>
let spotProvidersEnabledByDefault = { % raw let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
json_encode(web_ui_options["spot-providers-enabled-by-default"]) %
}
;
</script> </script>
<script src="/js/spotsbandsandmap.js?v=1781901371"></script> <script src="/js/spotsbandsandmap.js?v=1781901371"></script>
<script src="/js/bands.js?v=1781901371"></script> <script src="/js/bands.js?v=1781901371"></script>

View File

@@ -93,10 +93,7 @@
<script src="/vendor/js/leaflet-workedallbritainireland.js"></script> <script src="/vendor/js/leaflet-workedallbritainireland.js"></script>
<script> <script>
let spotProvidersEnabledByDefault = { % raw let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
json_encode(web_ui_options["spot-providers-enabled-by-default"]) %
}
;
</script> </script>
<script src="/js/spotsbandsandmap.js?v=1781901371"></script> <script src="/js/spotsbandsandmap.js?v=1781901371"></script>
<script src="/js/map.js?v=1781901371"></script> <script src="/js/map.js?v=1781901371"></script>

View File

@@ -114,10 +114,7 @@
</div> </div>
<script> <script>
let spotProvidersEnabledByDefault = { % raw let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
json_encode(web_ui_options["spot-providers-enabled-by-default"]) %
}
;
</script> </script>
<script src="/js/spotsbandsandmap.js?v=1781901371"></script> <script src="/js/spotsbandsandmap.js?v=1781901371"></script>
<script src="/js/spots.js?v=1781901371"></script> <script src="/js/spots.js?v=1781901371"></script>

View File

@@ -17,9 +17,11 @@ info:
### 2.0 ### 2.0
* POST `/spot` now supports upstream submission to external providers such as POTA and SOTA. The "add spot" API has a **breaking change** to enable this: instead of just posting the spot object itself as the JSON content of the POST, this has moved into a `spot` object within the structure. A new `handling` object alongside it contains the `submit_upstream`, `upstream_provider`, `upstream_credentials`, and `captcha_token` fields which control the server handling of the spot. * POST `/spot` now supports upstream submission to the spotting services associated with various SIGs.
* **Breaking change:** The "add spot" API has changed to enable this: instead of just posting the spot object itself as the JSON content of the POST, this has moved into a `spot` object within the structure. A new `handling` object alongside it contains the `submit_upstream`, `upstream_provider`, `upstream_credentials`, and `captcha_token` fields which control the server handling of the spot.
* POST `/spot` now supports Google reCaptcha and (if the site owner has set it up) now requires `captcha_token` in order to successfully submit. (This is used to lock down the submit function and prevent submission via Spothole by bots or third-party clients.) * POST `/spot` now supports Google reCaptcha and (if the site owner has set it up) now requires `captcha_token` in order to successfully submit. (This is used to lock down the submit function and prevent submission via Spothole by bots or third-party clients.)
* GET `/options` now returns `spot_submit_providers`, a map of SIG names to the names of providers that support upstream spot submission for that SIG. (This allows clients to present the user with options of where a new spot can be sent to.) * GET `/options` now returns `spot_submit_providers`, a map of SIG names to the names of providers that support upstream spot submission for that SIG. (This allows clients to present the user with options of where a new spot can be sent to.)
* **Breaking change:** A user's QRZ.com and HamQTH credentials are now supplied as request headers (`X-QRZ-Username`, `X-QRZ-Password`, `X-QRZ-Session-Key`, `X-HamQTH-Username`, `X-HamQTH-Password`, `X-HamQTH-Session-ID`) rather than query parameters, to keep credentials out of server logs.
### 1.3 ### 1.3
@@ -398,39 +400,39 @@ paths:
components: components:
parameters: parameters:
QrzUsername: QrzUsername:
name: qrz_username name: X-QRZ-Username
in: query in: header
description: "QRZ.com username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Requires a QRZ.com XML Subscriber (paid) account. Supply together with `qrz_password`, or supply `qrz_session_key` instead." description: "QRZ.com username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Requires a QRZ.com XML Subscriber (paid) account. Supply together with `X-QRZ-Password`, or supply `X-QRZ-Session-Key` instead."
schema: schema:
type: string type: string
QrzPassword: QrzPassword:
name: qrz_password name: X-QRZ-Password
in: query in: header
description: "QRZ.com password. Supply together with `qrz_username`." description: "QRZ.com password. Supply together with `X-QRZ-Username`."
schema: schema:
type: string type: string
QrzSessionKey: QrzSessionKey:
name: qrz_session_key name: X-QRZ-Session-Key
in: query in: header
description: "A pre-obtained QRZ.com XML session key, as an alternative to supplying `qrz_username` and `qrz_password`. See https://www.qrz.com/docs/xml/current_spec.html for details on how to obtain one for the user." description: "A pre-obtained QRZ.com XML session key, as an alternative to supplying `X-QRZ-Username` and `X-QRZ-Password`. See https://www.qrz.com/docs/xml/current_spec.html for details on how to obtain one for the user."
schema: schema:
type: string type: string
HamqthUsername: HamqthUsername:
name: hamqth_username name: X-HamQTH-Username
in: query in: header
description: "HamQTH username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Supply together with `hamqth_password`, or supply `hamqth_session_id` instead." description: "HamQTH username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Supply together with `X-HamQTH-Password`, or supply `X-HamQTH-Session-ID` instead."
schema: schema:
type: string type: string
HamqthPassword: HamqthPassword:
name: hamqth_password name: X-HamQTH-Password
in: query in: header
description: "HamQTH password. Supply together with `hamqth_username`." description: "HamQTH password. Supply together with `X-HamQTH-Username`."
schema: schema:
type: string type: string
HamqthSessionId: HamqthSessionId:
name: hamqth_session_id name: X-HamQTH-Session-ID
in: query in: header
description: "A pre-obtained HamQTH session ID, as an alternative to supplying `hamqth_username` and `hamqth_password`. See https://www.hamqth.com/developers.php for details on how to retrieve one for a user." description: "A pre-obtained HamQTH session ID, as an alternative to supplying `X-HamQTH-Username` and `X-HamQTH-Password`. See https://www.hamqth.com/developers.php for details on how to retrieve one for a user."
schema: schema:
type: string type: string
SpotSource: SpotSource:

View File

@@ -6,7 +6,7 @@ let alerts = [];
// Load alerts and populate the table. // Load alerts and populate the table.
function loadAlerts() { function loadAlerts() {
$.getJSON('/api/v2/alerts' + buildQueryString(false), function (jsonData) { $.ajax({url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
// Store last updated time // Store last updated time
lastUpdateTime = moment.utc(); lastUpdateTime = moment.utc();
updateRefreshDisplay(); updateRefreshDisplay();
@@ -14,11 +14,11 @@ function loadAlerts() {
alerts = jsonData; alerts = jsonData;
// Update table // Update table
updateTable(); updateTable();
}); }});
} }
// Build a query string for the API, based on the filters that the user has selected. // Build a query string for the API, based on the filters that the user has selected.
function buildQueryString(includeCredentials) { function buildQueryString() {
let str = "?"; let str = "?";
["dx_continent", "source"].forEach(fn => { ["dx_continent", "source"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) { if (!allFilterOptionsSelected(fn)) {
@@ -33,9 +33,6 @@ function buildQueryString(includeCredentials) {
if ($("#dxpeditions_skip_max_duration_check")[0].checked) { if ($("#dxpeditions_skip_max_duration_check")[0].checked) {
str = str + "&dxpeditions_skip_max_duration_check=true"; str = str + "&dxpeditions_skip_max_duration_check=true";
} }
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str; return str;
} }

View File

@@ -12,7 +12,7 @@ BAND_COLUMN_SPOT_DIV_HEIGHT_PX = BAND_COLUMN_FONT_SIZE * 1.6;
// Load spots and populate the bands display. // Load spots and populate the bands display.
function loadSpots() { function loadSpots() {
$.getJSON('/api/v2/spots' + buildQueryString(false), function (jsonData) { $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
// Store last updated time // Store last updated time
lastUpdateTime = moment.utc(); lastUpdateTime = moment.utc();
updateRefreshDisplay(); updateRefreshDisplay();
@@ -20,11 +20,11 @@ function loadSpots() {
spots = jsonData; spots = jsonData;
// Update bands display // Update bands display
updateBands(); updateBands();
}); }});
} }
// Build a query string for the API, based on the filters that the user has selected. // Build a query string for the API, based on the filters that the user has selected.
function buildQueryString(includeCredentials) { function buildQueryString() {
let str = "?"; let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => { ["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) { if (!allFilterOptionsSelected(fn)) {
@@ -34,9 +34,6 @@ function buildQueryString(includeCredentials) {
str = str + "max_age=" + $("#max-spot-age option:selected").val(); str = str + "max_age=" + $("#max-spot-age option:selected").val();
// Additional filters for the bands view: No dupes, no QRT // Additional filters for the bands view: No dupes, no QRT
str = str + "&dedupe=true&allow_qrt=false"; str = str + "&dedupe=true&allow_qrt=false";
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str; return str;
} }

View File

@@ -273,23 +273,23 @@ function closeDataPanel() {
closePanel("#data-area"); closePanel("#data-area");
} }
// Build a query string fragment containing any QRZ.com / HamQTH credentials the user has supplied, // Build a headers object containing any QRZ.com / HamQTH credentials the user has supplied,
// provided the corresponding "enabled" checkbox is ticked. // provided the corresponding "enabled" checkbox is ticked.
function getCredentialQueryString() { function getCredentialHeaders() {
let str = ""; const headers = {};
if ($("#qrz-enabled")[0] && $("#qrz-enabled")[0].checked) { if ($("#qrz-enabled")[0] && $("#qrz-enabled")[0].checked) {
const qrzUsername = $("#qrz-username").val(); const qrzUsername = $("#qrz-username").val();
const qrzPassword = $("#qrz-password").val(); const qrzPassword = $("#qrz-password").val();
if (qrzUsername) str += "&qrz_username=" + encodeURIComponent(qrzUsername); if (qrzUsername) headers["X-QRZ-Username"] = qrzUsername;
if (qrzPassword) str += "&qrz_password=" + encodeURIComponent(qrzPassword); if (qrzPassword) headers["X-QRZ-Password"] = qrzPassword;
} }
if ($("#hamqth-enabled")[0] && $("#hamqth-enabled")[0].checked) { if ($("#hamqth-enabled")[0] && $("#hamqth-enabled")[0].checked) {
const hamqthUsername = $("#hamqth-username").val(); const hamqthUsername = $("#hamqth-username").val();
const hamqthPassword = $("#hamqth-password").val(); const hamqthPassword = $("#hamqth-password").val();
if (hamqthUsername) str += "&hamqth_username=" + encodeURIComponent(hamqthUsername); if (hamqthUsername) headers["X-HamQTH-Username"] = hamqthUsername;
if (hamqthPassword) str += "&hamqth_password=" + encodeURIComponent(hamqthPassword); if (hamqthPassword) headers["X-HamQTH-Password"] = hamqthPassword;
} }
return str; return headers;
} }

View File

@@ -28,7 +28,7 @@ let firstLoad = true;
// Load spots and populate the map. // Load spots and populate the map.
function loadSpots() { function loadSpots() {
$.getJSON('/api/v2/spots' + buildQueryString(true), function (jsonData) { $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
// Store data // Store data
spots = jsonData; spots = jsonData;
// Update map // Update map
@@ -36,11 +36,11 @@ function loadSpots() {
if ($("#showTerminator")[0].checked) { if ($("#showTerminator")[0].checked) {
terminator.setTime(); terminator.setTime();
} }
}); }});
} }
// Build a query string for the API, based on the filters that the user has selected. // Build a query string for the API, based on the filters that the user has selected.
function buildQueryString(includeCredentials) { function buildQueryString() {
let str = "?"; let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => { ["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) { if (!allFilterOptionsSelected(fn)) {
@@ -50,9 +50,6 @@ function buildQueryString(includeCredentials) {
str = str + "max_age=" + $("#max-spot-age option:selected").val(); str = str + "max_age=" + $("#max-spot-age option:selected").val();
// Additional filters for the map view: No dupes, no QRT, only spots with good locations // Additional filters for the map view: No dupes, no QRT, only spots with good locations
str = str + "&dedupe=true&allow_qrt=false"; str = str + "&dedupe=true&allow_qrt=false";
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str; return str;
} }

View File

@@ -20,7 +20,7 @@ function loadSpots() {
} }
// Make the new query // Make the new query
$.getJSON('/api/v2/spots' + buildQueryString(false), function (jsonData) { $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
// Store data // Store data
spots = jsonData; spots = jsonData;
// Update table // Update table
@@ -30,7 +30,7 @@ function loadSpots() {
if (run) { if (run) {
startSSEConnection(); startSSEConnection();
} }
}); }});
} }
// Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the // Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the
@@ -39,7 +39,7 @@ function startSSEConnection() {
if (evtSource != null) { if (evtSource != null) {
evtSource.close(); evtSource.close();
} }
evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString(true)); evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString());
evtSource.onmessage = function (event) { evtSource.onmessage = function (event) {
// Get the new spot // Get the new spot
@@ -86,7 +86,7 @@ function startSSEConnection() {
} }
// Build a query string for the API, based on the filters that the user has selected. // Build a query string for the API, based on the filters that the user has selected.
function buildQueryString(includeCredentials) { function buildQueryString() {
let str = "?"; let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => { ["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) { if (!allFilterOptionsSelected(fn)) {
@@ -97,9 +97,6 @@ function buildQueryString(includeCredentials) {
if ($("#search").val() !== "") { if ($("#search").val() !== "") {
str = str + "&text_includes=" + encodeURIComponent($("#search").val()); str = str + "&text_includes=" + encodeURIComponent($("#search").val());
} }
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str; return str;
} }