Security Enhancements

Refer to changelog.md
This commit is contained in:
sassanix
2025-03-20 23:05:35 -03:00
parent ec7cb58c9f
commit b800742c3d
9 changed files with 239 additions and 38 deletions

View File

@@ -1,5 +1,20 @@
# Changelog
## [0.9.7.6] - 2025-03-21
- **Security Enhancements**
- **Secure File Access System**
- Implemented comprehensive security controls for uploaded files (invoices, manuals)
- Added two secure file serving endpoints with authentication and authorization
- Created client-side utility functions for secure file handling
- Blocked direct access to the uploads directory via nginx configuration
- Added ownership verification to ensure users can only access their own files
- Implemented protection against path traversal attacks
- Enhanced logging for all file access attempts
- **Frontend Security Integration**
- Created new file-utils.js with secureFilePath and openSecureFile functions
- Updated all file links to use secure handling methods
- Added proper authentication token handling for file downloads
## [0.9.7.5] - 2025-03-14
- **Fixes**
- Removed remember me

View File

@@ -93,16 +93,7 @@ RUN echo 'server {\n\
\n\
# Direct access to uploads directory\n\
location /uploads/ {\n\
alias /data/uploads/;\n\
autoindex on;\n\
\n\
# Set appropriate MIME types\n\
types {\n\
image/png png PNG;\n\
image/jpeg jpg jpeg JPG JPEG;\n\
application/pdf pdf PDF;\n\
text/plain txt TXT;\n\
}\n\
return 403 "Access forbidden";\n\
}\n\
}' > /etc/nginx/conf.d/default.conf

View File

@@ -260,13 +260,19 @@ def token_required(f):
if auth_header and auth_header.startswith('Bearer '):
token = auth_header.split(' ')[1]
# If no token in header, check form data for POST requests
if not token and request.method == 'POST':
token = request.form.get('auth_token') # Check form data
# If no token is provided
if not token:
logger.warning(f"Authentication attempt without token: {request.path}")
return jsonify({'message': 'Authentication token is missing!'}), 401
# Decode the token
user_id = decode_token(token)
if not user_id:
logger.warning(f"Invalid token used for: {request.path}")
return jsonify({'message': 'Invalid or expired token!'}), 401
# Check if user exists
@@ -1803,6 +1809,75 @@ def check_registration_status():
if conn:
release_db_connection(conn)
# File serving endpoints
@app.route('/api/files/<path:filename>', methods=['GET', 'POST'])
@token_required
def serve_file(filename):
"""Basic secure file serving with authentication."""
try:
logger.info(f"File access request for {filename} by user {request.user['id']}")
if not filename.startswith('uploads/'):
logger.warning(f"Attempted access to non-uploads file: {filename}")
return jsonify({"message": "Access denied"}), 403
# Remove 'uploads/' prefix for send_from_directory
file_path = filename[8:] if filename.startswith('uploads/') else filename
return send_from_directory('/data/uploads', file_path)
except Exception as e:
logger.error(f"Error serving file {filename}: {e}")
return jsonify({"message": "Error accessing file"}), 500
@app.route('/api/secure-file/<path:filename>', methods=['GET', 'POST'])
@token_required
def secure_file_access(filename):
"""Enhanced secure file serving with authorization checks."""
try:
logger.info(f"Secure file access request for {filename} by user {request.user['id']}")
# Security check for path traversal
if '..' in filename or filename.startswith('/'):
logger.warning(f"Potential path traversal attempt detected: {filename} by user {request.user['id']}")
return jsonify({"message": "Invalid file path"}), 400
# Check if user is authorized to access this file
conn = get_db_connection()
try:
with conn.cursor() as cur:
# Find warranties that reference this file
query = """
SELECT w.id, w.user_id
FROM warranties w
WHERE w.invoice_path = %s OR w.manual_path = %s
"""
cur.execute(query, (f"uploads/{filename}", f"uploads/{filename}"))
results = cur.fetchall()
# Check if user owns any of these warranties or is admin
user_id = request.user['id']
is_admin = request.user.get('is_admin', False)
authorized = is_admin # Admins can access all files
if not authorized and results:
for warranty_id, warranty_user_id in results:
if warranty_user_id == user_id:
authorized = True
break
if not authorized:
logger.warning(f"Unauthorized file access attempt: {filename} by user {user_id}")
return jsonify({"message": "You are not authorized to access this file"}), 403
# Serve the file securely
return send_from_directory('/data/uploads', filename)
finally:
release_db_connection(conn)
except Exception as e:
logger.error(f"Error in secure file access for {filename}: {e}")
return jsonify({"message": "Error accessing file"}), 500
# Initialize the database when the application starts
try:
init_db()

126
frontend/file-utils.js Normal file
View File

@@ -0,0 +1,126 @@
/**
* Utility functions for secure file handling
*/
/**
* Converts a file path to a secure API endpoint path
* @param {string} path - The file path to convert
* @returns {string} The secure API endpoint path
*/
function secureFilePath(path) {
if (!path) return '';
if (path.startsWith('uploads/')) {
return '/api/secure-file/' + path.substring(8);
}
if (path.startsWith('/uploads/')) {
return '/api/secure-file/' + path.substring(9);
}
return path;
}
/**
* Opens a file in a new tab with proper authentication
* @param {string} path - The file path to open
*/
function openSecureFile(path) {
if (!path) return;
const securePath = secureFilePath(path);
console.log('Opening file:', securePath);
// Try to get the token from different sources
const token = localStorage.getItem('auth_token');
if (!token) {
console.error('No authentication token available');
alert('You must be logged in to access files');
return;
}
// Create a link with target="_blank" and click it programmatically
const link = document.createElement('a');
link.href = securePath;
link.target = '_blank';
// Add a click event listener to inject the token
link.addEventListener('click', function(e) {
// Prevent the default navigation
e.preventDefault();
// Make a fetch request with the Authorization header
fetch(securePath, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.blob();
})
.then(blob => {
// Create a URL for the blob and open it in a new window
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
})
.catch(error => {
console.error('Error opening file:', error);
alert('Error opening file. Please try again or check if you are logged in.');
});
});
// Trigger the click event
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* Downloads a file using the secure file endpoint
* @param {string} path - The file path to download
* @param {string} filename - The name to save the file as
*/
function downloadSecureFile(path, filename) {
if (!path) return;
const securePath = secureFilePath(path);
const token = localStorage.getItem('auth_token');
if (!token) {
console.error('No authentication token available');
alert('You must be logged in to download files');
return;
}
fetch(securePath, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.blob();
})
.then(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename || path.split('/').pop();
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
})
.catch(error => {
console.error('Error downloading file:', error);
alert('Error downloading file. Please try again or check if you are logged in.');
});
}

View File

@@ -7,6 +7,9 @@
<!-- Include authentication script first to handle login state immediately -->
<script src="include-auth-new.js"></script>
<!-- File utilities script for secure file handling -->
<script src="file-utils.js"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Warranty Tracker</title>

View File

@@ -565,15 +565,13 @@ async function renderWarranties(warrantiesToRender) {
</a>
` : ''}
${warranty.invoice_path ? `
<a href="${warranty.invoice_path}" class="invoice-link" target="_blank">
<a href="#" onclick="openSecureFile('${warranty.invoice_path}'); return false;" class="invoice-link">
<i class="fas fa-file-invoice"></i> Invoice
</a>
` : ''}
</a>` : ''}
${warranty.manual_path ? `
<a href="${warranty.manual_path}" class="manual-link" target="_blank">
<a href="#" onclick="openSecureFile('${warranty.manual_path}'); return false;" class="manual-link">
<i class="fas fa-book"></i> Manual
</a>
` : ''}
</a>` : ''}
</div>
`;
} else if (currentView === 'list') {
@@ -616,15 +614,13 @@ async function renderWarranties(warrantiesToRender) {
</a>
` : ''}
${warranty.invoice_path ? `
<a href="${warranty.invoice_path}" class="invoice-link" target="_blank">
<a href="#" onclick="openSecureFile('${warranty.invoice_path}'); return false;" class="invoice-link">
<i class="fas fa-file-invoice"></i> Invoice
</a>
` : ''}
</a>` : ''}
${warranty.manual_path ? `
<a href="${warranty.manual_path}" class="manual-link" target="_blank">
<a href="#" onclick="openSecureFile('${warranty.manual_path}'); return false;" class="manual-link">
<i class="fas fa-book"></i> Manual
</a>
` : ''}
</a>` : ''}
</div>
`;
} else if (currentView === 'table') {
@@ -657,15 +653,13 @@ async function renderWarranties(warrantiesToRender) {
</a>
` : ''}
${warranty.invoice_path ? `
<a href="${warranty.invoice_path}" class="invoice-link" target="_blank">
<a href="#" onclick="openSecureFile('${warranty.invoice_path}'); return false;" class="invoice-link">
<i class="fas fa-file-invoice"></i>
</a>
` : ''}
</a>` : ''}
${warranty.manual_path ? `
<a href="${warranty.manual_path}" class="manual-link" target="_blank">
<a href="#" onclick="openSecureFile('${warranty.manual_path}'); return false;" class="manual-link">
<i class="fas fa-book"></i>
</a>
` : ''}
</a>` : ''}
</div>
`;
}
@@ -774,7 +768,7 @@ function openEditModal(warranty) {
currentInvoiceElement.innerHTML = `
<span class="text-success">
<i class="fas fa-check-circle"></i> Current invoice:
<a href="${warranty.invoice_path}" target="_blank">View</a>
<a href="#" onclick="openSecureFile('${warranty.invoice_path}'); return false;">View</a>
(Upload a new file to replace)
</span>
`;
@@ -790,7 +784,7 @@ function openEditModal(warranty) {
currentManualElement.innerHTML = `
<span class="text-success">
<i class="fas fa-check-circle"></i> Current manual:
<a href="${warranty.manual_path}" target="_blank">View</a>
<a href="#" onclick="openSecureFile('${warranty.manual_path}'); return false;">View</a>
(Upload a new file to replace)
</span>
`;

View File

@@ -7,6 +7,9 @@
<!-- Include authentication script first to handle login state immediately -->
<script src="include-auth-new.js"></script>
<!-- File utilities script for secure file handling -->
<script src="file-utils.js"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Status - Warranty Tracker</title>

View File

@@ -613,7 +613,7 @@ function filterAndSortWarranties() {
<a href="index.html?edit=${warranty.id}" class="action-btn edit-btn" title="Edit">
<i class="fas fa-edit"></i>
</a>
<a href="${warranty.invoice_path}" target="_blank" class="action-btn view-btn" title="View Invoice" ${!warranty.invoice_path ? 'style="display: none;"' : ''}>
<a href="#" onclick="openSecureFile('${warranty.invoice_path}'); return false;" class="action-btn view-btn" title="View Invoice" ${!warranty.invoice_path ? 'style="display: none;"' : ''}>
<i class="fas fa-file-alt"></i>
</a>
</td>

View File

@@ -62,13 +62,7 @@ server {
# Uploads - serve files from uploads directory
location /uploads/ {
alias /data/uploads/;
autoindex on;
# No caching
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
return 403 "Access forbidden";
}
# HTML files - ensure proper content type