Security Fixes

This commit is contained in:
Tim Zöller 2026-04-07 10:28:10 +02:00
parent aa7a7bc9fc
commit a0eebfcb3f
14 changed files with 279 additions and 37 deletions

View file

@ -51,17 +51,14 @@ public class AuthController {
.body(null); .body(null);
} }
// Check registration password if configured // Check registration password if configured.
// Check for both null and blank (empty or whitespace-only strings) // NEVER log the configured or provided password values they are credentials.
log.debug("Registration password check - configured: '{}', provided: '{}'",
configuredRegistrationPassword, request.getRegistrationPassword());
if (configuredRegistrationPassword != null && !configuredRegistrationPassword.trim().isEmpty()) { if (configuredRegistrationPassword != null && !configuredRegistrationPassword.trim().isEmpty()) {
String providedPassword = request.getRegistrationPassword(); String providedPassword = request.getRegistrationPassword();
if (providedPassword == null || providedPassword.trim().isEmpty() || if (providedPassword == null || providedPassword.trim().isEmpty() ||
!configuredRegistrationPassword.equals(providedPassword)) { !configuredRegistrationPassword.equals(providedPassword)) {
log.warn("Registration attempt with invalid registration password for username: {} (expected: '{}', got: '{}')", log.warn("Registration attempt with invalid registration password for username: {}",
request.getUsername(), configuredRegistrationPassword, providedPassword); request.getUsername());
throw new IllegalArgumentException("Invalid registration password"); throw new IllegalArgumentException("Invalid registration password");
} }
log.info("Registration password validated successfully for username: {}", request.getUsername()); log.info("Registration password validated successfully for username: {}", request.getUsername());

View file

@ -100,14 +100,20 @@ public class TimelineController {
@AuthenticationPrincipal(errorOnInvalidType = false) UserDetails userDetails, @AuthenticationPrincipal(errorOnInvalidType = false) UserDetails userDetails,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String search @RequestParam(required = false) String search,
@RequestParam(required = false) String hashtag
) { ) {
UUID userId = null; UUID userId = null;
if (userDetails != null) { if (userDetails != null) {
userId = getUserId(userDetails); userId = getUserId(userDetails);
log.debug("Public timeline request from authenticated user: {} (search: {})", userId, search); log.debug("Public timeline request from authenticated user: {} (search: {}, hashtag: {})", userId, search, hashtag);
} else { } else {
log.debug("Public timeline request (unauthenticated) (search: {})", search); log.debug("Public timeline request (unauthenticated) (search: {}, hashtag: {})", search, hashtag);
}
// Reject malformed hashtags (extraction only allows \w)
if (hashtag != null && !hashtag.matches("\\w+")) {
hashtag = null;
} }
// Sort by activity start date descending (latest first) // Sort by activity start date descending (latest first)
@ -115,9 +121,9 @@ public class TimelineController {
// Use search if filters provided, otherwise use standard timeline // Use search if filters provided, otherwise use standard timeline
Page<TimelineActivityDTO> timeline; Page<TimelineActivityDTO> timeline;
if (search != null) { if (search != null || hashtag != null) {
timeline = timelineService.searchPublicTimeline( timeline = timelineService.searchPublicTimeline(
userId, search, pageable userId, search, hashtag, pageable
); );
} else { } else {
timeline = timelineService.getPublicTimeline(userId, pageable); timeline = timelineService.getPublicTimeline(userId, pageable);

View file

@ -369,6 +369,10 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
OR LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%')) OR LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%'))
OR LOWER(a.activity_location) LIKE LOWER(CONCAT('%', :searchText, '%')) OR LOWER(a.activity_location) LIKE LOWER(CONCAT('%', :searchText, '%'))
)) ))
AND (CAST(:hashtagPattern AS text) IS NULL OR (
a.title ~* CAST(:hashtagPattern AS text)
OR a.description ~* CAST(:hashtagPattern AS text)
))
GROUP BY a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at, GROUP BY a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at,
a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss, a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss,
a.simplified_track, a.track_points_json, a.created_at, a.updated_at, a.simplified_track, a.track_points_json, a.created_at, a.updated_at,
@ -377,6 +381,7 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
""", nativeQuery = true) """, nativeQuery = true)
Page<Object[]> searchPublicTimeline(@Param("visibility") String visibility, Page<Object[]> searchPublicTimeline(@Param("visibility") String visibility,
@Param("searchText") String searchText, @Param("searchText") String searchText,
@Param("hashtagPattern") String hashtagPattern,
@Param("currentUserId") UUID currentUserId, @Param("currentUserId") UUID currentUserId,
Pageable pageable); Pageable pageable);

View file

@ -23,16 +23,44 @@ public class JwtTokenProvider {
private final SecretKey secretKey; private final SecretKey secretKey;
private final long validityInMilliseconds; private final long validityInMilliseconds;
/**
* Known insecure placeholder values that may have leaked into deployment configs.
* The bean refuses to initialize if the configured secret matches any of them.
*/
private static final java.util.Set<String> KNOWN_PLACEHOLDERS = java.util.Set.of(
"change-this-secret-key-in-production-must-be-at-least-32-characters-long",
"changeme"
);
public JwtTokenProvider( public JwtTokenProvider(
@Value("${fitpub.security.jwt.secret}") String secret, @Value("${fitpub.security.jwt.secret:}") String secret,
@Value("${fitpub.security.jwt.expiration:86400000}") long validityInMilliseconds @Value("${fitpub.security.jwt.expiration:86400000}") long validityInMilliseconds
) { ) {
if (secret == null || secret.isBlank()) {
throw new IllegalStateException(
"JWT secret is not configured. Set the JWT_SECRET environment variable to a random value of at least 32 characters."
);
}
if (KNOWN_PLACEHOLDERS.contains(secret)) {
throw new IllegalStateException(
"JWT secret is set to a known placeholder value. Generate a real secret (e.g. `openssl rand -base64 48`) and set JWT_SECRET."
);
}
// Ensure secret is long enough for HS256 (at least 256 bits / 32 bytes) // Ensure secret is long enough for HS256 (at least 256 bits / 32 bytes)
if (secret.getBytes(StandardCharsets.UTF_8).length < 32) { if (secret.getBytes(StandardCharsets.UTF_8).length < 32) {
throw new IllegalArgumentException("JWT secret must be at least 32 characters long"); throw new IllegalStateException(
"JWT secret must be at least 32 bytes long (HS256 requirement). Current length: "
+ secret.getBytes(StandardCharsets.UTF_8).length
);
} }
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.validityInMilliseconds = validityInMilliseconds; this.validityInMilliseconds = validityInMilliseconds;
// Log a fingerprint, never the secret itself
log.info("JWT signing key initialised (length={} bytes, fingerprint={}…{})",
secret.getBytes(StandardCharsets.UTF_8).length,
secret.substring(0, Math.min(4, secret.length())),
secret.substring(Math.max(0, secret.length() - 4))
);
} }
/** /**

View file

@ -216,18 +216,27 @@ public class TimelineService {
public Page<TimelineActivityDTO> searchPublicTimeline( public Page<TimelineActivityDTO> searchPublicTimeline(
UUID userId, UUID userId,
String searchText, String searchText,
String hashtag,
Pageable pageable Pageable pageable
) { ) {
log.debug("Searching public timeline (userId: {}, search: {})", log.debug("Searching public timeline (userId: {}, search: {}, hashtag: {})",
userId, searchText); userId, searchText, hashtag);
// Create unsorted Pageable since ORDER BY is already in the native query // Create unsorted Pageable since ORDER BY is already in the native query
Pageable unsortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); Pageable unsortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize());
// Build a POSIX regex matching #hashtag as a standalone token (case-insensitive via ~*).
// The hashtag value contains only \w characters (extraction enforces this), so no escaping needed.
String hashtagPattern = null;
if (hashtag != null && !hashtag.isBlank()) {
hashtagPattern = "(^|[^[:alnum:]_])#" + hashtag + "([^[:alnum:]_]|$)";
}
// Use optimized search query with JOINs and WHERE conditions // Use optimized search query with JOINs and WHERE conditions
Page<Object[]> results = activityRepository.searchPublicTimeline( Page<Object[]> results = activityRepository.searchPublicTimeline(
Activity.Visibility.PUBLIC.name(), Activity.Visibility.PUBLIC.name(),
searchText, searchText,
hashtagPattern,
userId, userId,
unsortedPageable unsortedPageable
); );

View file

@ -65,9 +65,8 @@ public class GpxFileValidator {
*/ */
private void validateGpxStructure(byte[] fileData) { private void validateGpxStructure(byte[] fileData) {
try { try {
// Parse XML to check well-formedness // Parse XML to check well-formedness, using a hardened factory (defends against XXE).
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilderFactory factory = SecureXmlFactories.newDocumentBuilderFactory(true);
factory.setNamespaceAware(true);
DocumentBuilder builder = factory.newDocumentBuilder(); DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new ByteArrayInputStream(fileData)); Document doc = builder.parse(new ByteArrayInputStream(fileData));

View file

@ -59,9 +59,8 @@ public class GpxParser {
ParsedActivityData parsedData = new ParsedActivityData(); ParsedActivityData parsedData = new ParsedActivityData();
parsedData.setSourceFormat("GPX"); parsedData.setSourceFormat("GPX");
// Parse XML // Parse XML using a hardened factory (defends against XXE).
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilderFactory factory = SecureXmlFactories.newDocumentBuilderFactory(true);
factory.setNamespaceAware(true);
DocumentBuilder builder = factory.newDocumentBuilder(); DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new ByteArrayInputStream(fileData)); Document doc = builder.parse(new ByteArrayInputStream(fileData));

View file

@ -0,0 +1,48 @@
package net.javahippie.fitpub.util;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
/**
* Shared factory helpers that produce XML parsers hardened against XXE
* (XML External Entity) attacks. All XML parsing in the application that
* touches user-controlled bytes (e.g. uploaded GPX files) MUST go through
* one of these helpers rather than calling {@link DocumentBuilderFactory#newInstance()}
* directly.
*
* <p>The hardening applied here disables DTDs, external entities, parameter
* entities, and external DTD loading, and enables the JAXP secure-processing
* feature. Together these defeat the standard XXE payloads (file disclosure
* via {@code SYSTEM "file:///..."}, billion laughs, SSRF via external entities).
*/
public final class SecureXmlFactories {
private SecureXmlFactories() {
}
/**
* Returns a {@link DocumentBuilderFactory} hardened against XXE.
*
* @param namespaceAware whether to enable namespace awareness
* @return a hardened factory
*/
public static DocumentBuilderFactory newDocumentBuilderFactory(boolean namespaceAware) {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(namespaceAware);
try {
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
} catch (ParserConfigurationException e) {
// Any underlying parser that does not support these features is
// unsafe fail loudly rather than silently fall back.
throw new IllegalStateException("Failed to configure secure XML parser", e);
}
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
return factory;
}
}

View file

@ -72,7 +72,9 @@ fitpub:
# Security settings # Security settings
security: security:
jwt: jwt:
secret: ${JWT_SECRET:change-this-secret-key-in-production-must-be-at-least-32-characters-long} # JWT_SECRET must be set explicitly. The dev profile (application-dev.yml) provides a development default;
# production deployments without JWT_SECRET set will fail to start.
secret: ${JWT_SECRET:}
expiration: 86400000 # 24 hours in milliseconds expiration: 86400000 # 24 hours in milliseconds
# Registration settings # Registration settings

View file

@ -1360,3 +1360,14 @@ h1 {
color: #000000; color: #000000;
font-weight: 900; font-weight: 900;
} }
/* Hashtag links inside titles, descriptions, and detail pages */
.hashtag-link {
color: #0d6efd;
text-decoration: none;
font-weight: 500;
}
.hashtag-link:hover {
text-decoration: underline;
}

View file

@ -9,6 +9,7 @@ const FitPubTimeline = {
timelineType: 'public', timelineType: 'public',
searchText: '', searchText: '',
dateFilter: '', dateFilter: '',
hashtagFilter: '',
searchDebounceTimer: null, searchDebounceTimer: null,
/** /**
@ -17,7 +18,45 @@ const FitPubTimeline = {
*/ */
init: function(type) { init: function(type) {
this.timelineType = type; this.timelineType = type;
// Read hashtag filter from URL query string
const params = new URLSearchParams(window.location.search);
const hashtagParam = params.get('hashtag');
if (hashtagParam && /^\w+$/.test(hashtagParam)) {
this.hashtagFilter = hashtagParam;
}
this.setupSearchHandlers(); this.setupSearchHandlers();
this.renderHashtagFilterBadge();
this.loadTimeline(0);
},
/**
* Show or hide the active hashtag filter badge
*/
renderHashtagFilterBadge: function() {
const badge = document.getElementById('hashtagFilterBadge');
if (!badge) return;
if (this.hashtagFilter) {
const label = badge.querySelector('#hashtagFilterLabel');
if (label) label.textContent = '#' + this.hashtagFilter;
badge.classList.remove('d-none');
} else {
badge.classList.add('d-none');
}
},
/**
* Clear the active hashtag filter
*/
clearHashtagFilter: function() {
this.hashtagFilter = '';
this.renderHashtagFilterBadge();
// Update URL without reload
const url = new URL(window.location.href);
url.searchParams.delete('hashtag');
window.history.replaceState({}, '', url);
this.loadTimeline(0); this.loadTimeline(0);
}, },
@ -68,6 +107,10 @@ const FitPubTimeline = {
endpoint += `&search=${encodeURIComponent(this.searchText)}`; endpoint += `&search=${encodeURIComponent(this.searchText)}`;
} }
if (this.hashtagFilter) {
endpoint += `&hashtag=${encodeURIComponent(this.hashtagFilter)}`;
}
if (this.dateFilter) { if (this.dateFilter) {
// Only add if valid format // Only add if valid format
const validation = this.validateDateFormat(this.dateFilter); const validation = this.validateDateFormat(this.dateFilter);
@ -166,18 +209,14 @@ const FitPubTimeline = {
<!-- Activity Title and Description --> <!-- Activity Title and Description -->
<h5 class="card-title"> <h5 class="card-title">
${activity.isLocal ${activity.isLocal
? `<a href="/activities/${activity.id}" class="text-decoration-none text-dark"> ? this.renderTitleLinkWithHashtags(activity.title, `/activities/${activity.id}`, 'activity-title-link', '')
${this.escapeHtml(activity.title || 'Untitled Activity')} : this.renderTitleLinkWithHashtags(activity.title, activity.activityUri || '#', 'activity-title-link', 'target="_blank"')
</a>` + (activity.isLocal ? '' : ' <i class="bi bi-box-arrow-up-right ms-1 small"></i>')
: `<a href="${activity.activityUri || '#'}" target="_blank" class="text-decoration-none text-dark">
${this.escapeHtml(activity.title || 'Untitled Activity')}
<i class="bi bi-box-arrow-up-right ms-1 small"></i>
</a>`
} }
</h5> </h5>
${activity.description ${activity.description
? `<p class="card-text">${this.escapeHtml(activity.description).substring(0, 200)}${activity.description.length > 200 ? '...' : ''}</p>` ? `<p class="card-text">${this.linkifyHashtags(activity.description.length > 200 ? activity.description.substring(0, 200) + '...' : activity.description)}</p>`
: '' : ''
} }
@ -562,6 +601,58 @@ const FitPubTimeline = {
return div.innerHTML; return div.innerHTML;
}, },
/**
* Escape text for safe HTML insertion AND turn #hashtags into links
* pointing to the public timeline filtered by that hashtag.
* @param {string} text - Text to process
* @returns {string} HTML-safe string with hashtag anchors
*/
linkifyHashtags: function(text) {
if (!text) return '';
const escaped = this.escapeHtml(text);
return escaped.replace(/(^|\s)#(\w+)/g, (match, lead, tag) =>
`${lead}<a href="/timeline?hashtag=${encodeURIComponent(tag.toLowerCase())}" class="hashtag-link">#${tag}</a>`
);
},
/**
* Render a title that links to an activity, with embedded #hashtags
* linking instead to the public timeline filtered by that tag.
* Avoids invalid nested <a> tags by rendering segments as siblings.
* @param {string} text - Title text
* @param {string} activityHref - Link target for non-hashtag portions
* @param {string} extraClass - Extra CSS classes for the activity link segments
* @param {string} extraAttrs - Extra HTML attributes for the activity link segments
* @returns {string} HTML
*/
renderTitleLinkWithHashtags: function(text, activityHref, extraClass, extraAttrs) {
const safeText = text || 'Untitled Activity';
extraClass = extraClass || '';
extraAttrs = extraAttrs || '';
const wrapActivity = (chunk) =>
chunk
? `<a href="${activityHref}" class="text-decoration-none text-dark ${extraClass}" ${extraAttrs}>${chunk}</a>`
: '';
const parts = [];
const regex = /(^|\s)#(\w+)/g;
let last = 0;
let m;
while ((m = regex.exec(safeText)) !== null) {
// Text before the hashtag (and the leading whitespace) goes to activity
const before = safeText.substring(last, m.index) + m[1];
if (before) parts.push(wrapActivity(this.escapeHtml(before)));
const tag = m[2];
parts.push(
`<a href="/timeline?hashtag=${encodeURIComponent(tag.toLowerCase())}" class="hashtag-link">#${this.escapeHtml(tag)}</a>`
);
last = m.index + m[0].length;
}
const tail = safeText.substring(last);
if (tail) parts.push(wrapActivity(this.escapeHtml(tail)));
return parts.join('');
},
/** /**
* Render indoor activity placeholder with emoji * Render indoor activity placeholder with emoji
* @param {HTMLElement} element - Container element * @param {HTMLElement} element - Container element

View file

@ -519,7 +519,7 @@
function renderActivity(activity) { function renderActivity(activity) {
// Header // Header
document.getElementById('activityTitle').textContent = activity.title || 'Untitled Activity'; document.getElementById('activityTitle').innerHTML = linkifyHashtags(activity.title || 'Untitled Activity');
document.getElementById('activityType').textContent = activity.activityType; document.getElementById('activityType').textContent = activity.activityType;
document.getElementById('activityType').className = `activity-type-badge activity-type-${activity.activityType.toLowerCase()}`; document.getElementById('activityType').className = `activity-type-badge activity-type-${activity.activityType.toLowerCase()}`;
// Format date with timezone awareness // Format date with timezone awareness
@ -570,7 +570,7 @@
// Description // Description
if (activity.description) { if (activity.description) {
document.getElementById('activityDescription').textContent = activity.description; document.getElementById('activityDescription').innerHTML = linkifyHashtags(activity.description);
} else { } else {
document.getElementById('activityDescription').style.display = 'none'; document.getElementById('activityDescription').style.display = 'none';
} }
@ -1721,6 +1721,14 @@
return div.innerHTML; return div.innerHTML;
} }
function linkifyHashtags(text) {
if (!text) return '';
const escaped = escapeHtml(text);
return escaped.replace(/(^|\s)#(\w+)/g, (match, lead, tag) =>
`${lead}<a href="/timeline?hashtag=${encodeURIComponent(tag.toLowerCase())}" class="hashtag-link">#${tag}</a>`
);
}
// Delete functionality // Delete functionality
document.getElementById('deleteBtn').addEventListener('click', function() { document.getElementById('deleteBtn').addEventListener('click', function() {
const modal = new bootstrap.Modal(document.getElementById('deleteModal')); const modal = new bootstrap.Modal(document.getElementById('deleteModal'));

View file

@ -152,9 +152,7 @@
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-8">
<h5 class="card-title"> <h5 class="card-title">
<a href="/activities/${activity.id}" class="text-decoration-none"> ${renderTitleLinkWithHashtags(activity.title, `/activities/${activity.id}`)}
${escapeHtml(activity.title || 'Untitled Activity')}
</a>
</h5> </h5>
<p class="text-muted mb-2"> <p class="text-muted mb-2">
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}${activity.race ? ' race-activity' : ''}"> <span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}${activity.race ? ' race-activity' : ''}">
@ -181,7 +179,7 @@
${activity.visibility} ${activity.visibility}
</span> </span>
</p> </p>
${activity.description ? `<p class="card-text">${escapeHtml(activity.description).substring(0, 150)}${activity.description.length > 150 ? '...' : ''}</p>` : ''} ${activity.description ? `<p class="card-text">${linkifyHashtags(activity.description.length > 150 ? activity.description.substring(0, 150) + '...' : activity.description)}</p>` : ''}
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<small class="text-muted"> <small class="text-muted">
@ -332,6 +330,35 @@
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
function linkifyHashtags(text) {
if (!text) return '';
const escaped = escapeHtml(text);
return escaped.replace(/(^|\s)#(\w+)/g, (match, lead, tag) =>
`${lead}<a href="/timeline?hashtag=${encodeURIComponent(tag.toLowerCase())}" class="hashtag-link">#${tag}</a>`
);
}
function renderTitleLinkWithHashtags(text, activityHref) {
const safeText = text || 'Untitled Activity';
const wrap = (chunk) => chunk
? `<a href="${activityHref}" class="text-decoration-none activity-title-link">${chunk}</a>`
: '';
const parts = [];
const regex = /(^|\s)#(\w+)/g;
let last = 0;
let m;
while ((m = regex.exec(safeText)) !== null) {
const before = safeText.substring(last, m.index) + m[1];
if (before) parts.push(wrap(escapeHtml(before)));
const tag = m[2];
parts.push(`<a href="/timeline?hashtag=${encodeURIComponent(tag.toLowerCase())}" class="hashtag-link">#${escapeHtml(tag)}</a>`);
last = m.index + m[0].length;
}
const tail = safeText.substring(last);
if (tail) parts.push(wrap(escapeHtml(tail)));
return parts.join('');
}
}); });
</script> </script>
</th:block> </th:block>

View file

@ -62,6 +62,18 @@
</div> </div>
</div> </div>
<!-- Active hashtag filter -->
<div id="hashtagFilterBadge" class="alert alert-info d-flex justify-content-between align-items-center d-none" role="alert">
<span>
<i class="bi bi-hash"></i>
Filtering by <strong id="hashtagFilterLabel">#tag</strong>
</span>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="FitPubTimeline.clearHashtagFilter(); return false;">
<i class="bi bi-x-lg"></i> Clear
</button>
</div>
<!-- Loading Indicator --> <!-- Loading Indicator -->
<div id="loadingIndicator" class="text-center py-5"> <div id="loadingIndicator" class="text-center py-5">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">