Repository: tkrajina/gpxpy

Issue:
Custom attributes in `to_xml` not escaped properly
It seems that if `to_xml()` is called to produce a XML file, certain conditions can cause a XML with invalid syntax to be produced.

This happens whenever there are custom attributes in a waypoint. Even though the regular attributes seem to escape properly (e.g. `&` to `&amp;`), this validation doesn't seem to be done for the custom attributes. Here is an example of a failure case:

```
  <wpt lat="52.4824535" lon="13.4451467">
    <time>2024-02-12T12:48:53Z</time>
    <name>Du Beast drinks &amp; coffee</name>
    <desc>Innstraße 4, Neukölln</desc>
    <sym>amenity_pub</sym>
    <extensions>
      <osmand:amenity_name>Du Beast drinks & coffee</osmand:amenity_name>
      <osmand:amenity_origin>Amenity:Du Beast drinks & coffee: sustenance:bar</osmand:amenity_origin>
    </extensions>
  </wpt>
```

This is invalid XML cause there is a disallowed ampersand at line 7 (`drinks & coffee`).

This can easily be reproduced by using a GPX file with custom attributes (e.g. from OSMAnd in this case) that contain any of the special characters that are invalid in XML such as `&`.

Discussion:

Patch:
diff --git a/gpxpy/gpxfield.py b/gpxpy/gpxfield.py
index d31200a..e253df3 100644
--- a/gpxpy/gpxfield.py
+++ b/gpxpy/gpxfield.py
@@ -16,6 +16,7 @@ import inspect as mod_inspect
 import datetime as mod_datetime
 import re as mod_re
 import copy as mod_copy
+import xml.sax.saxutils as mod_saxutils
 
 from . import utils as mod_utils
 
@@ -400,8 +401,7 @@ class GPXExtensionsField(AbstractGPXField):
             result.append(f' {attrib}="{value}"')
         result.append('>')
         if node.text is not None:
-             result.append(node.text.strip())
-
+            result.append(mod_saxutils.escape(node.text).strip())
 
         # Build subelement nodes
         for child in node:
@@ -412,7 +412,7 @@ class GPXExtensionsField(AbstractGPXField):
         # Add tail and close tag
         tail = node.tail
         if tail is not None:
-            tail = tail.strip()
+            tail = mod_saxutils.escape(tail.strip())
         else:
             tail = ''
         if len(node) > 0:


Test patch:
diff --git a/test.py b/test.py
index e5b60d5..7dd6f44 100644
--- a/test.py
+++ b/test.py
@@ -2649,6 +2649,18 @@ class GPXTests(mod_unittest.TestCase):
 
         self.assertTrue('<name>Test&lt;a&gt;jkljkl&lt;/gpx&gt;</name>' in gpx_2.to_xml())
 
+    def test_xml_chars_encode_decode_extensions(self) -> None:
+        gpx = mod_gpxpy.gpx.GPX()
+        ext = mod_etree.Element('test')
+        ext.text = "Test<a>jkljkl</gpx>"
+        ext.tail = "<&tail>"
+        gpx.extensions.append(ext)
+        print(gpx.to_xml())
+        gpx_2 = mod_gpxpy.parse(gpx.to_xml())
+        self.assertTrue('<test>Test&lt;a&gt;jkljkl&lt;/gpx&gt;</test>' in gpx_2.to_xml())
+        self.assertTrue('&lt;&amp;tail&gt;' in gpx_2.to_xml())
+        
+
     def test_10_to_11_conversion(self) -> None:
         """
         This test checks that reparsing from 1.0 to 1.1 and from 1.1 to 1.0