From 5bdeb7b13a5099e5e98ff1d7e9ca96a5331737ae Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 1 Jun 2026 20:34:21 -0400 Subject: [PATCH] 06/01 App enhanced --- app/__init__.py | 10 ++++ app/routes/teller.py | 73 +++++++++++++++------------- app/routes/transactions.py | 12 +++-- app/services/ai_service.py | 2 + app/templates/transactions/form.html | 55 +++++++++++++++++++++ 5 files changed, 114 insertions(+), 38 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index e0e4f88..77030dd 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -116,6 +116,16 @@ def create_app(config_name=None): ) from app.models.teller_enrollment import TellerEnrollment, TellerAccount + @app.after_request + def security_headers(response): + response.headers['X-Frame-Options'] = 'DENY' + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-XSS-Protection'] = '1; mode=block' + response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' + if not app.debug: + response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' + return response + app.jinja_env.globals['format_currency'] = format_currency app.jinja_env.globals['format_percent'] = format_percent app.jinja_env.globals['format_large_number'] = format_large_number diff --git a/app/routes/teller.py b/app/routes/teller.py index fb93ca4..b0d84d9 100644 --- a/app/routes/teller.py +++ b/app/routes/teller.py @@ -359,47 +359,50 @@ def webhook(): # Header format: Teller-Signature: t=,v1=,v1= # Signed message: . signing_secret = current_app.config.get('TELLER_WEBHOOK_SECRET', '') - if signing_secret: - sig_header = request.headers.get('Teller-Signature', '') - body = request.get_data() + if not signing_secret: + log.error('[teller] TELLER_WEBHOOK_SECRET not configured — rejecting webhook') + return jsonify({'error': 'Webhook verification not configured'}), 403 - if not sig_header: - log.warning('[teller] missing Teller-Signature header') - return jsonify({'error': 'Missing signature'}), 401 + sig_header = request.headers.get('Teller-Signature', '') + body = request.get_data() - # Parse header: t=,v1=,v1=... - parts = dict( - (p.split('=', 1) if '=' in p else (p, '')) - for p in sig_header.split(',') - ) - timestamp = parts.get('t', '') - # Collect all v1 signatures (may be multiple during key rotation) - signatures = [v for k, v in - [p.split('=', 1) for p in sig_header.split(',') if p.startswith('v1=')] - ] + if not sig_header: + log.warning('[teller] missing Teller-Signature header') + return jsonify({'error': 'Missing signature'}), 401 - if not timestamp or not signatures: - log.warning('[teller] malformed Teller-Signature header') - return jsonify({'error': 'Invalid signature'}), 401 + # Parse header: t=,v1=,v1=... + parts = dict( + (p.split('=', 1) if '=' in p else (p, '')) + for p in sig_header.split(',') + ) + timestamp = parts.get('t', '') + # Collect all v1 signatures (may be multiple during key rotation) + signatures = [v for k, v in + [p.split('=', 1) for p in sig_header.split(',') if p.startswith('v1=')] + ] - # Reject replays older than 5 minutes - import time - try: - if abs(time.time() - int(timestamp)) > 300: - log.warning('[teller] webhook replay attack detected') - return jsonify({'error': 'Timestamp too old'}), 401 - except ValueError: - pass + if not timestamp or not signatures: + log.warning('[teller] malformed Teller-Signature header') + return jsonify({'error': 'Invalid signature'}), 401 - # signed_message = timestamp + "." + raw_body - signed_message = f'{timestamp}.'.encode() + body - expected = hmac.new( - signing_secret.encode(), signed_message, hashlib.sha256 - ).hexdigest() + # Reject replays older than 5 minutes + import time + try: + if abs(time.time() - int(timestamp)) > 300: + log.warning('[teller] webhook replay attack detected') + return jsonify({'error': 'Timestamp too old'}), 401 + except ValueError: + pass - if not any(hmac.compare_digest(expected, sig) for sig in signatures): - log.warning('[teller] webhook signature mismatch') - return jsonify({'error': 'Invalid signature'}), 401 + # signed_message = timestamp + "." + raw_body + signed_message = f'{timestamp}.'.encode() + body + expected = hmac.new( + signing_secret.encode(), signed_message, hashlib.sha256 + ).hexdigest() + + if not any(hmac.compare_digest(expected, sig) for sig in signatures): + log.warning('[teller] webhook signature mismatch') + return jsonify({'error': 'Invalid signature'}), 401 # Teller sends GET to verify the endpoint is reachable if request.method == 'GET': diff --git a/app/routes/transactions.py b/app/routes/transactions.py index ce1995e..6d28c34 100644 --- a/app/routes/transactions.py +++ b/app/routes/transactions.py @@ -261,9 +261,15 @@ def ocr_receipt(): if size > 10 * 1024 * 1024: return jsonify({'error': 'File too large (max 10MB)'}), 400 - mime_map = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', - '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp'} - mime_type = mime_map.get(ext, 'image/jpeg') + # Validate actual file content via magic bytes + from app.routes.settings import _check_magic + header = f.read(12) + f.seek(0) + magic_ext, magic_mime = _check_magic(header) + if magic_ext is None or magic_ext not in ('jpg', 'jpeg', 'png', 'gif', 'webp'): + return jsonify({'error': 'File content does not match an allowed image type'}), 400 + + mime_type = magic_mime image_bytes = f.read() result = extract_from_bytes(image_bytes, mime_type) diff --git a/app/services/ai_service.py b/app/services/ai_service.py index 73e84d9..f65f900 100644 --- a/app/services/ai_service.py +++ b/app/services/ai_service.py @@ -10,6 +10,7 @@ from datetime import date, timedelta from flask import current_app from sqlalchemy import func from app.extensions import db +from sqlalchemy.orm import joinedload from app.models.transaction import Transaction from app.models.category import Category from app.models.account import Account @@ -127,6 +128,7 @@ def build_context(days=90): # ── Recent 20 transactions ──────────────────────────────────────────────── recent = Transaction.query\ + .options(joinedload(Transaction.category))\ .filter( Transaction.transaction_type.in_(['income', 'expense']), Transaction.date >= since, diff --git a/app/templates/transactions/form.html b/app/templates/transactions/form.html index 37d9c17..f33b8fb 100644 --- a/app/templates/transactions/form.html +++ b/app/templates/transactions/form.html @@ -304,6 +304,61 @@ function ocrExistingReceipt(filename) { }); } +// ── Client-side form validation ─────────────────────────────────────────────── +document.getElementById('txnForm').addEventListener('submit', function(e) { + let valid = true; + + const desc = document.getElementById('field_description'); + if (!desc.value.trim()) { + desc.classList.add('is-invalid'); + if (!desc.nextElementSibling || !desc.nextElementSibling.classList.contains('invalid-feedback')) { + const err = document.createElement('div'); + err.className = 'invalid-feedback'; + err.textContent = 'Description is required.'; + desc.after(err); + } + valid = false; + } else { + desc.classList.remove('is-invalid'); + } + + const amt = document.getElementById('field_amount'); + const amtVal = parseFloat(amt.value); + const amtParent = amt.closest('.input-group') || amt; + if (!amt.value || isNaN(amtVal) || amtVal <= 0) { + amt.classList.add('is-invalid'); + let errEl = amtParent.parentElement.querySelector('.amt-error'); + if (!errEl) { + errEl = document.createElement('div'); + errEl.className = 'text-danger mt-1 amt-error'; + errEl.style.fontSize = '12px'; + amtParent.after(errEl); + } + errEl.textContent = amtVal < 0 ? 'Amount must be positive.' : 'Amount is required.'; + valid = false; + } else { + amt.classList.remove('is-invalid'); + const errEl = amtParent.parentElement.querySelector('.amt-error'); + if (errEl) errEl.remove(); + } + + const dt = document.getElementById('field_date'); + if (!dt.value) { + dt.classList.add('is-invalid'); + valid = false; + } else { + dt.classList.remove('is-invalid'); + } + + if (!valid) e.preventDefault(); +}); + +// Clear validation state on input +['field_description', 'field_amount', 'field_date'].forEach(id => { + const el = document.getElementById(id); + if (el) el.addEventListener('input', () => el.classList.remove('is-invalid')); +}); + // ── Auto-OCR when receipt is selected in edit form ──────────────────────────── function autoOcrOnUpload(input) { const file = input.files[0];