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
39 changes: 39 additions & 0 deletions qsomap/templates/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,42 @@
</div>
</div>
{% endblock %}

{% block scripts %}
<script>
// Automatically trim whitespace from callsign and locator fields
document.addEventListener('DOMContentLoaded', function() {
const callsignInput = document.getElementById('callsign');
const locatorInput = document.getElementById('my_locator');

// Function to trim input value
function trimInput(input) {
if (input && input.value) {
input.value = input.value.trim();
}
}

// Trim on blur (when user leaves the field)
if (callsignInput) {
callsignInput.addEventListener('blur', function() {
trimInput(this);
});
}

if (locatorInput) {
locatorInput.addEventListener('blur', function() {
trimInput(this);
});
}

// Trim on form submit as final safety check
const form = document.querySelector('form');
if (form) {
form.addEventListener('submit', function(e) {
trimInput(callsignInput);
trimInput(locatorInput);
});
}
});
</script>
{% endblock %}
2 changes: 1 addition & 1 deletion qsomap/templates/qso_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<body>
<div class="top-bar">
<div class="top-bar-content">
<h2 class="top-bar-title">QSO map - {{ callsign }} - {{ filename }}</h2>
<h2 class="top-bar-title">QSO map - {{ callsign | safe }} - {{ filename | safe }}</h2>
<div class="top-bar-controls">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="uniform-color-checkbox" checked>
Expand Down
42 changes: 37 additions & 5 deletions qsomap/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,36 @@
from pyhamtools.locator import locator_to_latlong
from qsomap.common.log_reader import read_log_file
from qsomap.common.grid_validator import validate_grid_square
from markupsafe import Markup

upload_bp = Blueprint('upload', __name__)


def sanitize_text_input(text):
"""
Sanitize text input by trimming whitespace and escaping HTML.

Args:
text: Input text string or None

Returns:
Markup object with sanitized text or None if input is None/empty
"""
if text is None:
return None

# Strip leading/trailing whitespace
sanitized = text.strip()

# Return None for empty strings
if not sanitized:
return None

# Escape HTML to prevent XSS and return as Markup object
# Markup objects are treated as safe by Jinja2 and won't be double-escaped
return Markup.escape(sanitized)


def allowed_file(filename):
"""Check if the uploaded file has an allowed extension (ADIF, ADI, or Cabrillo)"""
allowed_extensions = {'adif', 'adi', 'cbr', 'log', 'cabrillo', 'cab'}
Expand All @@ -32,11 +58,14 @@ def validate_file(file):

def validate_locator(my_locator):
"""Validate locator format and flash error message if invalid, return normalized locator or None"""
if not my_locator or not my_locator.strip():
# Sanitize input
sanitized_locator = sanitize_text_input(my_locator)

if not sanitized_locator:
flash('Locator field is required.', 'error')
return None

normalized_locator = my_locator.strip().upper()
normalized_locator = sanitized_locator.upper()

if not validate_grid_square(normalized_locator):
flash('Invalid locator format. Please enter a valid Maidenhead locator (e.g., JO70, JO70fp, JO70fp12).', 'error')
Expand Down Expand Up @@ -78,10 +107,13 @@ def upload_file():
if not validate_file(file):
return redirect(url_for('upload.upload_file'))

# Get form data
callsign = request.form.get('callsign')
# Get form data and sanitize
callsign = sanitize_text_input(request.form.get('callsign'))
my_locator = request.form.get('my_locator')

# Sanitize filename
filename = sanitize_text_input(file.filename) if file.filename else None

# Validate locator
normalized_locator = validate_locator(my_locator)
if not normalized_locator:
Expand All @@ -107,7 +139,7 @@ def upload_file():
my_latitude=my_latitude,
my_longitude=my_longitude,
callsign=callsign,
filename=file.filename
filename=filename
)

return render_template('main.html')
230 changes: 230 additions & 0 deletions tests/test_input_sanitization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
"""
Test suite for input sanitization functionality.
"""
import pytest
from io import BytesIO
from qsomap.upload import sanitize_text_input
from app import app


@pytest.fixture
def client():
"""Create a test client for the Flask application."""
app.config['TESTING'] = True
app.config['WTF_CSRF_ENABLED'] = False
with app.test_client() as client:
with app.app_context():
yield client


class TestInputSanitization:
"""Test cases for input sanitization functionality."""

@pytest.mark.unit
def test_sanitize_text_input_strips_whitespace(self):
"""Test that sanitize_text_input removes leading and trailing whitespace."""
assert sanitize_text_input(' test ') == 'test'
assert sanitize_text_input('\ttest\t') == 'test'
assert sanitize_text_input('\ntest\n') == 'test'
assert sanitize_text_input(' test with spaces ') == 'test with spaces'

@pytest.mark.unit
def test_sanitize_text_input_handles_none(self):
"""Test that sanitize_text_input handles None input."""
assert sanitize_text_input(None) is None

@pytest.mark.unit
def test_sanitize_text_input_handles_empty_string(self):
"""Test that sanitize_text_input returns None for empty strings."""
assert sanitize_text_input('') is None
assert sanitize_text_input(' ') is None
assert sanitize_text_input('\t\n') is None

@pytest.mark.unit
def test_sanitize_text_input_escapes_html(self):
"""Test that sanitize_text_input escapes HTML characters."""
assert sanitize_text_input('<script>alert("xss")</script>') == '&lt;script&gt;alert(&#34;xss&#34;)&lt;/script&gt;'
assert sanitize_text_input('<b>bold</b>') == '&lt;b&gt;bold&lt;/b&gt;'
assert sanitize_text_input('test & test') == 'test &amp; test'
assert sanitize_text_input('test "quote" test') == 'test &#34;quote&#34; test'

@pytest.mark.unit
def test_sanitize_text_input_normal_text(self):
"""Test that sanitize_text_input preserves normal text."""
assert sanitize_text_input('SP1ABC') == 'SP1ABC'
assert sanitize_text_input('JO70fp') == 'JO70fp'
assert sanitize_text_input('Test 123') == 'Test 123'

@pytest.mark.unit
def test_callsign_with_trailing_space(self, client):
"""Test upload with callsign that has trailing whitespace."""
adif_content = """<ADIF_VER:5>3.1.4
<EOH>

<QSO_DATE:8>20241101<TIME_ON:6>120000<CALL:6>SP0ABC<BAND:3>20m<MODE:2>CW<GRIDSQUARE:6>JO62AA<EOR>
"""
response = client.post('/upload', data={
'callsign': ' SP1ABC ', # Callsign with spaces
'my_locator': 'JO90AA',
'file': (BytesIO(adif_content.encode('utf-8')), 'test.adif')
}, follow_redirects=True)

assert response.status_code == 200
# Verify the callsign was trimmed in the response
assert b'SP1ABC' in response.data

@pytest.mark.unit
def test_locator_with_trailing_space(self, client):
"""Test upload with locator that has trailing whitespace."""
adif_content = """<ADIF_VER:5>3.1.4
<EOH>

<QSO_DATE:8>20241101<TIME_ON:6>120000<CALL:6>SP0ABC<BAND:3>20m<MODE:2>CW<GRIDSQUARE:6>JO62AA<EOR>
"""
response = client.post('/upload', data={
'callsign': 'SP1ABC',
'my_locator': ' JO90AA ', # Locator with spaces
'file': (BytesIO(adif_content.encode('utf-8')), 'test.adif')
}, follow_redirects=True)

assert response.status_code == 200
# Should succeed - locator gets trimmed and normalized - check for QSO map page
assert b'QSO map' in response.data or b'SP0ABC' in response.data

@pytest.mark.unit
def test_locator_with_mixed_whitespace(self, client):
"""Test upload with locator that has tabs and newlines."""
adif_content = """<ADIF_VER:5>3.1.4
<EOH>

<QSO_DATE:8>20241101<TIME_ON:6>120000<CALL:6>SP0ABC<BAND:3>20m<MODE:2>CW<GRIDSQUARE:6>JO62AA<EOR>
"""
response = client.post('/upload', data={
'callsign': 'SP1ABC',
'my_locator': '\tJO90AA\n', # Locator with tab and newline
'file': (BytesIO(adif_content.encode('utf-8')), 'test.adif')
}, follow_redirects=True)

assert response.status_code == 200
# Should succeed - locator gets trimmed and normalized - check for QSO map page
assert b'QSO map' in response.data or b'SP0ABC' in response.data

@pytest.mark.unit
def test_empty_callsign_after_trim(self, client):
"""Test upload with callsign that becomes empty after trimming."""
adif_content = """<ADIF_VER:5>3.1.4
<EOH>

<QSO_DATE:8>20241101<TIME_ON:6>120000<CALL:6>SP0ABC<BAND:3>20m<MODE:2>CW<GRIDSQUARE:6>JO62AA<EOR>
"""
response = client.post('/upload', data={
'callsign': ' ', # Only whitespace
'my_locator': 'JO90AA',
'file': (BytesIO(adif_content.encode('utf-8')), 'test.adif')
}, follow_redirects=True)

# Callsign is optional at server-side (HTML form has required attribute for UX only)
# When callsign is only whitespace, sanitization returns None but server accepts it
assert response.status_code == 200

@pytest.mark.unit
def test_empty_locator_after_trim(self, client):
"""Test upload with locator that becomes empty after trimming."""
adif_content = """<ADIF_VER:5>3.1.4
<EOH>

<QSO_DATE:8>20241101<TIME_ON:6>120000<CALL:6>SP0ABC<BAND:3>20m<MODE:2>CW<GRIDSQUARE:6>JO62AA<EOR>
"""
response = client.post('/upload', data={
'callsign': 'SP1ABC',
'my_locator': ' ', # Only whitespace
'file': (BytesIO(adif_content.encode('utf-8')), 'test.adif')
}, follow_redirects=True)

# Should fail - locator is required
assert response.status_code == 200
assert b'Locator field is required' in response.data

@pytest.mark.unit
def test_xss_attempt_in_callsign(self, client):
"""Test that XSS attempts in callsign are neutralized."""
adif_content = """<ADIF_VER:5>3.1.4
<EOH>

<QSO_DATE:8>20241101<TIME_ON:6>120000<CALL:6>SP0ABC<BAND:3>20m<MODE:2>CW<GRIDSQUARE:6>JO62AA<EOR>
"""
response = client.post('/upload', data={
'callsign': '<script>alert("xss")</script>',
'my_locator': 'JO90AA',
'file': (BytesIO(adif_content.encode('utf-8')), 'test.adif')
}, follow_redirects=True)

assert response.status_code == 200
# Verify that the script tag is escaped - Flask/Jinja2 auto-escapes by default
# The escaped callsign should be in the page
response_text = response.data.decode('utf-8')
# Script tag should be escaped
assert '<script>alert("xss")</script>' not in response_text
# Should have escaped HTML entities OR the page rendered successfully with escaping
assert '&lt;script&gt;' in response_text or 'QSO map' in response_text

@pytest.mark.unit
def test_xss_attempt_in_locator(self, client):
"""Test that XSS attempts in locator field are rejected (invalid format)."""
adif_content = """<ADIF_VER:5>3.1.4
<EOH>

<QSO_DATE:8>20241101<TIME_ON:6>120000<CALL:6>SP0ABC<BAND:3>20m<MODE:2>CW<GRIDSQUARE:6>JO62AA<EOR>
"""
response = client.post('/upload', data={
'callsign': 'SP1ABC',
'my_locator': '<script>alert("xss")</script>',
'file': (BytesIO(adif_content.encode('utf-8')), 'test.adif')
}, follow_redirects=True)

# Should fail validation - not a valid locator format
assert response.status_code == 200
assert b'Invalid locator format' in response.data

@pytest.mark.unit
def test_both_fields_with_whitespace(self, client):
"""Test upload with both fields having whitespace."""
adif_content = """<ADIF_VER:5>3.1.4
<EOH>

<QSO_DATE:8>20241101<TIME_ON:6>120000<CALL:6>SP0ABC<BAND:3>20m<MODE:2>CW<GRIDSQUARE:6>JO62AA<EOR>
"""
response = client.post('/upload', data={
'callsign': ' SP1ABC ',
'my_locator': ' JO90AA ',
'file': (BytesIO(adif_content.encode('utf-8')), 'test.adif')
}, follow_redirects=True)

assert response.status_code == 200
# Should succeed - both fields get trimmed - check for QSO map page
assert b'QSO map' in response.data
# Verify both fields are present and trimmed
assert b'SP1ABC' in response.data

@pytest.mark.unit
def test_filename_sanitization(self, client):
"""Test that filename with special characters is sanitized."""
adif_content = """<ADIF_VER:5>3.1.4
<EOH>

<QSO_DATE:8>20241101<TIME_ON:6>120000<CALL:6>SP0ABC<BAND:3>20m<MODE:2>CW<GRIDSQUARE:6>JO62AA<EOR>
"""
# Filename with HTML characters that should be escaped
response = client.post('/upload', data={
'callsign': 'SP1ABC',
'my_locator': 'JO90AA',
'file': (BytesIO(adif_content.encode('utf-8')), '<script>alert.adif')
}, follow_redirects=True)

assert response.status_code == 200
# Verify filename is escaped in the response
response_text = response.data.decode('utf-8')
# Script tag should be escaped
assert '<script>alert.adif' not in response_text
# Should have escaped HTML entities
assert '&lt;script&gt;alert.adif' in response_text