refactor(tasks): extract filename regex to shared constant

Move the valid filename regex pattern to a shared constant in `app/utils/filename_utils.py` and update both the task logic and security tests to use it. This eliminates duplication and ensures consistency across the codebase.

Changes:
- Defined `VALID_FILENAME_PATTERN` and `VALID_FILENAME_RE` in `app/utils/filename_utils.py`.
- Updated `app/tasks/extract_metadata_with_gpt.py` to use `VALID_FILENAME_RE`.
- Updated `tests/test_path_traversal_security.py` to use `VALID_FILENAME_PATTERN`.

This refactoring addresses the duplication mentioned in the TODO in `tests/test_path_traversal_security.py`.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot]
2026-03-23 18:58:29 +00:00
parent 031b51b9b8
commit 6f510d5a2d
3 changed files with 201 additions and 194 deletions
+192 -191
View File
@@ -1,191 +1,192 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
import logging import logging
import os import os
import re import re
# Import the shared Celery instance # Import the shared Celery instance
from app.celery_app import celery from app.celery_app import celery
from app.config import settings from app.config import settings
from app.database import SessionLocal from app.database import SessionLocal
from app.models import FileRecord from app.models import FileRecord
from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf
from app.tasks.retry_config import BaseTaskWithRetry from app.tasks.retry_config import BaseTaskWithRetry
from app.utils import log_task_progress from app.utils import log_task_progress
from app.utils.ai_provider import get_ai_provider from app.utils.ai_provider import get_ai_provider
from app.utils.filename_utils import VALID_FILENAME_RE
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
def extract_json_from_text(text):
""" def extract_json_from_text(text):
Try to extract a JSON object from the text. """
- First, check for a JSON block inside triple backticks. Try to extract a JSON object from the text.
- If not found, try to extract text from the first '{' to the last '}'. - First, check for a JSON block inside triple backticks.
""" - If not found, try to extract text from the first '{' to the last '}'.
pattern = r"```(?:json)?\s*(\{.*?\})\s*```" """
match = re.search(pattern, text, re.DOTALL) pattern = r"```(?:json)?\s*(\{.*?\})\s*```"
if match: match = re.search(pattern, text, re.DOTALL)
return match.group(1) if match:
else: return match.group(1)
start = text.find("{") else:
end = text.rfind("}") start = text.find("{")
if start != -1 and end != -1 and end > start: end = text.rfind("}")
return text[start : end + 1] if start != -1 and end != -1 and end > start:
return None return text[start : end + 1]
return None
@celery.task(base=BaseTaskWithRetry, bind=True)
def extract_metadata_with_gpt(self, filename: str, cleaned_text: str, file_id: int = None): @celery.task(base=BaseTaskWithRetry, bind=True)
""" def extract_metadata_with_gpt(self, filename: str, cleaned_text: str, file_id: int = None):
Uses OpenAI to classify document metadata. """
Uses OpenAI to classify document metadata.
Args:
filename: Can be either a basename (e.g., "file.pdf") or a full path (e.g., "/workdir/processed/file.pdf") Args:
cleaned_text: The extracted text from the document filename: Can be either a basename (e.g., "file.pdf") or a full path (e.g., "/workdir/processed/file.pdf")
file_id: Optional file ID for tracking cleaned_text: The extracted text from the document
""" file_id: Optional file ID for tracking
task_id = self.request.id """
logger.info(f"[{task_id}] Starting metadata extraction for: {filename}") task_id = self.request.id
log_task_progress( logger.info(f"[{task_id}] Starting metadata extraction for: {filename}")
task_id, log_task_progress(
"extract_metadata_with_gpt", task_id,
"in_progress", "extract_metadata_with_gpt",
f"Extracting metadata for {os.path.basename(filename)}", "in_progress",
file_id=file_id, f"Extracting metadata for {os.path.basename(filename)}",
) file_id=file_id,
)
# Get file_id from database if not provided
if file_id is None: # Get file_id from database if not provided
tmp_dir = os.path.join(settings.workdir, "tmp") if file_id is None:
# Handle both basename and full path tmp_dir = os.path.join(settings.workdir, "tmp")
if os.path.isabs(filename): # Handle both basename and full path
file_path = filename if os.path.isabs(filename):
else: file_path = filename
file_path = os.path.join(tmp_dir, filename) else:
if os.path.exists(file_path): file_path = os.path.join(tmp_dir, filename)
with SessionLocal() as db: if os.path.exists(file_path):
file_record = db.query(FileRecord).filter_by(local_filename=file_path).first() with SessionLocal() as db:
if file_record: file_record = db.query(FileRecord).filter_by(local_filename=file_path).first()
file_id = file_record.id if file_record:
file_id = file_record.id
prompt = (
"You are a specialized document analyzer trained to extract structured metadata from documents.\n" prompt = (
"Your task is to analyze the given text and return a well-structured JSON object.\n\n" "You are a specialized document analyzer trained to extract structured metadata from documents.\n"
"Extract and return the following fields:\n" "Your task is to analyze the given text and return a well-structured JSON object.\n\n"
"1. **filename**: Machine-readable filename " "Extract and return the following fields:\n"
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, periods, and underscores).\n" "1. **filename**: Machine-readable filename "
'2. **empfaenger**: The recipient, or "Unknown" if not found.\n' "(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, periods, and underscores).\n"
'3. **absender**: The sender, or "Unknown" if not found.\n' '2. **empfaenger**: The recipient, or "Unknown" if not found.\n'
"4. **correspondent**: The entity or company that issued the document " '3. **absender**: The sender, or "Unknown" if not found.\n'
'(shortest possible name, e.g., "Amazon" instead of "Amazon EU SARL, German branch").\n' "4. **correspondent**: The entity or company that issued the document "
"5. **kommunikationsart**: One of [Behoerdlicher_Brief, Rechnung, Kontoauszug, Vertrag, " '(shortest possible name, e.g., "Amazon" instead of "Amazon EU SARL, German branch").\n'
"Quittung, Privater_Brief, Einladung, Gewerbliche_Korrespondenz, Newsletter, Werbung, Sonstiges].\n" "5. **kommunikationsart**: One of [Behoerdlicher_Brief, Rechnung, Kontoauszug, Vertrag, "
"6. **kommunikationskategorie**: One of [Amtliche_Postbehoerdliche_Dokumente, " "Quittung, Privater_Brief, Einladung, Gewerbliche_Korrespondenz, Newsletter, Werbung, Sonstiges].\n"
"Finanz_und_Vertragsdokumente, Geschaeftliche_Kommunikation, " "6. **kommunikationskategorie**: One of [Amtliche_Postbehoerdliche_Dokumente, "
"Private_Korrespondenz, Sonstige_Informationen].\n" "Finanz_und_Vertragsdokumente, Geschaeftliche_Kommunikation, "
"7. **document_type**: Precise classification (e.g., Invoice, Contract, Information, Unknown).\n" "Private_Korrespondenz, Sonstige_Informationen].\n"
"8. **tags**: A list of up to 4 relevant thematic keywords.\n" "7. **document_type**: Precise classification (e.g., Invoice, Contract, Information, Unknown).\n"
'9. **language**: Detected document language (ISO 639-1 code, e.g., "de" or "en").\n' "8. **tags**: A list of up to 4 relevant thematic keywords.\n"
"10. **title**: A human-readable title summarizing the document content.\n" '9. **language**: Detected document language (ISO 639-1 code, e.g., "de" or "en").\n'
"11. **confidence_score**: A numeric value (0-100) indicating the confidence level " "10. **title**: A human-readable title summarizing the document content.\n"
"of the extracted metadata.\n" "11. **confidence_score**: A numeric value (0-100) indicating the confidence level "
"12. **reference_number**: Extracted invoice/order/reference number if available.\n" "of the extracted metadata.\n"
"13. **monetary_amounts**: A list of key monetary values detected in the document.\n\n" "12. **reference_number**: Extracted invoice/order/reference number if available.\n"
"### Important Rules:\n" "13. **monetary_amounts**: A list of key monetary values detected in the document.\n\n"
"- **OCR Correction**: Assume the text has been corrected for OCR errors.\n" "### Important Rules:\n"
"- **Tagging**: Max 4 tags, avoiding generic or overly specific terms.\n" "- **OCR Correction**: Assume the text has been corrected for OCR errors.\n"
"- **Title**: Concise, no addresses, and contains key identifying features.\n" "- **Tagging**: Max 4 tags, avoiding generic or overly specific terms.\n"
"- **Date Selection**: Use the most relevant date if multiple are found.\n" "- **Title**: Concise, no addresses, and contains key identifying features.\n"
"- **Output Language**: Maintain the document's original language.\n\n" "- **Date Selection**: Use the most relevant date if multiple are found.\n"
f"Extracted text:\n{cleaned_text}\n\n" "- **Output Language**: Maintain the document's original language.\n\n"
"Return only valid JSON with no additional commentary.\n" f"Extracted text:\n{cleaned_text}\n\n"
) "Return only valid JSON with no additional commentary.\n"
)
try:
logger.info(f"[{task_id}] Sending classification request for {filename}...") try:
log_task_progress(task_id, "call_ai_provider", "in_progress", "Calling AI provider API", file_id=file_id) logger.info(f"[{task_id}] Sending classification request for {filename}...")
provider = get_ai_provider() log_task_progress(task_id, "call_ai_provider", "in_progress", "Calling AI provider API", file_id=file_id)
model = settings.ai_model or settings.openai_model provider = get_ai_provider()
content = provider.chat_completion( model = settings.ai_model or settings.openai_model
messages=[ content = provider.chat_completion(
{"role": "system", "content": "You are an intelligent document classifier."}, messages=[
{"role": "user", "content": prompt}, {"role": "system", "content": "You are an intelligent document classifier."},
], {"role": "user", "content": prompt},
model=model, ],
temperature=0, model=model,
) temperature=0,
)
logger.info(f"[{task_id}] Raw classification response for {filename}: {content[:200]}...")
log_task_progress( logger.info(f"[{task_id}] Raw classification response for {filename}: {content[:200]}...")
task_id, log_task_progress(
"call_ai_provider", task_id,
"success", "call_ai_provider",
"Received AI provider response", "success",
file_id=file_id, "Received AI provider response",
detail=f"Raw classification response:\n{content}", file_id=file_id,
) detail=f"Raw classification response:\n{content}",
)
json_text = extract_json_from_text(content)
if not json_text: json_text = extract_json_from_text(content)
logger.error(f"[{task_id}] Could not find valid JSON in GPT response for {filename}.") if not json_text:
log_task_progress( logger.error(f"[{task_id}] Could not find valid JSON in GPT response for {filename}.")
task_id, log_task_progress(
"extract_metadata_with_gpt", task_id,
"failure", "extract_metadata_with_gpt",
"Invalid JSON in response", "failure",
file_id=file_id, "Invalid JSON in response",
detail=f"Could not parse valid JSON from GPT response.\nRaw response:\n{content}", file_id=file_id,
) detail=f"Could not parse valid JSON from GPT response.\nRaw response:\n{content}",
return {} )
return {}
metadata = json.loads(json_text)
metadata = json.loads(json_text)
# SECURITY: Validate filename format from GPT to prevent path traversal
# The prompt requests filenames with only letters, numbers, periods, and underscores # SECURITY: Validate filename format from GPT to prevent path traversal
# Enforce this constraint to prevent malicious filenames # The prompt requests filenames with only letters, numbers, periods, and underscores
suggested_filename = metadata.get("filename", "") # Enforce this constraint to prevent malicious filenames
if suggested_filename: suggested_filename = metadata.get("filename", "")
# Check if filename contains only safe characters AND explicitly check for ".." if suggested_filename:
# Defense in depth: While the regex [\w\-\. ]+ already excludes / and \, # Check if filename contains only safe characters AND explicitly check for ".."
# we explicitly reject ".." to guard against: # Defense in depth: While the regex VALID_FILENAME_PATTERN already excludes / and \,
# 1. Potential locale-specific \w behavior # we explicitly reject ".." to guard against:
# 2. Files literally named ".." which are valid but problematic # 1. Potential locale-specific \w behavior
# 3. Future code changes that might relax the regex # 2. Files literally named ".." which are valid but problematic
if not re.match(r"^[\w\-\. ]+$", suggested_filename) or ".." in suggested_filename: # 3. Future code changes that might relax the regex
logger.warning(f"[{task_id}] Invalid filename format from GPT: '{suggested_filename}', using fallback") if not VALID_FILENAME_RE.match(suggested_filename) or ".." in suggested_filename:
# Reset to empty to trigger fallback to original filename logger.warning(f"[{task_id}] Invalid filename format from GPT: '{suggested_filename}', using fallback")
metadata["filename"] = "" # Reset to empty to trigger fallback to original filename
metadata["filename"] = ""
logger.info(f"[{task_id}] Extracted metadata: {metadata}")
log_task_progress( logger.info(f"[{task_id}] Extracted metadata: {metadata}")
task_id, log_task_progress(
"parse_metadata", task_id,
"success", "parse_metadata",
f"Parsed metadata: {list(metadata.keys())}", "success",
file_id=file_id, f"Parsed metadata: {list(metadata.keys())}",
detail=f"Extracted metadata:\n{json.dumps(metadata, ensure_ascii=False, indent=2)}", file_id=file_id,
) detail=f"Extracted metadata:\n{json.dumps(metadata, ensure_ascii=False, indent=2)}",
)
# Trigger the next step: embedding metadata into the PDF
# Pass the filename (can be basename or full path) so embed_metadata_into_pdf can find the file on disk # Trigger the next step: embedding metadata into the PDF
logger.info(f"[{task_id}] Queueing metadata embedding task") # Pass the filename (can be basename or full path) so embed_metadata_into_pdf can find the file on disk
log_task_progress( logger.info(f"[{task_id}] Queueing metadata embedding task")
task_id, "extract_metadata_with_gpt", "success", "Metadata extracted, queuing embed task", file_id=file_id log_task_progress(
) task_id, "extract_metadata_with_gpt", "success", "Metadata extracted, queuing embed task", file_id=file_id
embed_metadata_into_pdf.delay(filename, cleaned_text, metadata, file_id) )
embed_metadata_into_pdf.delay(filename, cleaned_text, metadata, file_id)
return {"s3_file": os.path.basename(filename), "metadata": metadata}
return {"s3_file": os.path.basename(filename), "metadata": metadata}
except Exception as e:
logger.exception(f"[{task_id}] AI provider classification failed for {filename}: {e}") except Exception as e:
log_task_progress( logger.exception(f"[{task_id}] AI provider classification failed for {filename}: {e}")
task_id, log_task_progress(
"extract_metadata_with_gpt", task_id,
"failure", "extract_metadata_with_gpt",
f"Exception: {str(e)}", "failure",
file_id=file_id, f"Exception: {str(e)}",
detail=f"AI provider classification failed for {filename}.\nException: {str(e)}", file_id=file_id,
) detail=f"AI provider classification failed for {filename}.\nException: {str(e)}",
return {} )
return {}
+5
View File
@@ -8,6 +8,11 @@ from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Pattern for valid filenames (alphanumeric, dash, underscore, period, and space)
# Used for validating GPT-provided filenames and other inputs
VALID_FILENAME_PATTERN = r"^[\w\-\. ]+$"
VALID_FILENAME_RE = re.compile(VALID_FILENAME_PATTERN)
def get_unique_filename(original_path: str, check_exists_func: Callable[[str], bool] | None = None) -> str: def get_unique_filename(original_path: str, check_exists_func: Callable[[str], bool] | None = None) -> str:
""" """
+4 -3
View File
@@ -212,9 +212,10 @@ class TestExtractMetadataFilenameValidation:
"""Test that invalid filename formats are rejected.""" """Test that invalid filename formats are rejected."""
import re import re
# Valid pattern from extract_metadata_with_gpt.py from app.utils.filename_utils import VALID_FILENAME_PATTERN
# TODO: Consider extracting this to a shared constant to avoid duplication
valid_pattern = r"^[\w\-\. ]+$" # Valid pattern from app.utils.filename_utils
valid_pattern = VALID_FILENAME_PATTERN
# Test valid filenames # Test valid filenames
valid_filenames = [ valid_filenames = [