diff --git a/.env.example b/.env.example index 59373dbcf..0c00478f0 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ NODE_ENV=production +# IMPORTANT: Replace with a strong random value before deploying. +# Generate one with: python3 -c "import secrets; print(secrets.token_urlsafe(48))" AUTH_SECRET=superscretchangeitplz # Generate your own master key by running: python3 -c "import os, base64; key = os.urandom(32); print('base64:' + base64.b64encode(key).decode('utf-8'))" MASTER_VAULT_KEY_V1=base64:qnHTmwYb+uoygIw9MsRMY22vS5YPchY+QOi/E79GAvM= diff --git a/flowsint-api/app/api/routes/flows.py b/flowsint-api/app/api/routes/flows.py index c80a7f88d..2bb0403f0 100644 --- a/flowsint-api/app/api/routes/flows.py +++ b/flowsint-api/app/api/routes/flows.py @@ -80,7 +80,7 @@ def get_flows( @router.get("/raw_materials") -async def get_material_list(): +async def get_material_list(current_user: Profile = Depends(get_current_user)): enrichers = ENRICHER_REGISTRY.list_by_categories() enricher_categories = { category: [ @@ -131,7 +131,9 @@ async def get_material_list(): @router.get("/input_type/{input_type}") -async def get_material_by_input_type(input_type: str): +async def get_material_by_input_type( + input_type: str, current_user: Profile = Depends(get_current_user) +): enrichers = ENRICHER_REGISTRY.list_by_input_type(input_type) return {"items": enrichers} diff --git a/flowsint-api/app/api/schemas/profile.py b/flowsint-api/app/api/schemas/profile.py index 1a286e0ca..cb1b26691 100644 --- a/flowsint-api/app/api/schemas/profile.py +++ b/flowsint-api/app/api/schemas/profile.py @@ -1,11 +1,13 @@ -from .base import ORMBase -from pydantic import UUID4, BaseModel, ConfigDict, EmailStr from typing import Optional +from pydantic import UUID4, BaseModel, ConfigDict, EmailStr, Field + +from .base import ORMBase + class ProfileCreate(BaseModel): email: EmailStr - password: str + password: str = Field(..., min_length=8, max_length=128) class ProfileRead(ORMBase): diff --git a/flowsint-api/app/main.py b/flowsint-api/app/main.py index e45875fe2..959fb7850 100644 --- a/flowsint-api/app/main.py +++ b/flowsint-api/app/main.py @@ -4,19 +4,21 @@ from fastapi.middleware.cors import CORSMiddleware # Routes to be included -from app.api.routes import auth -from app.api.routes import investigations -from app.api.routes import sketches -from app.api.routes import enrichers -from app.api.routes import flows -from app.api.routes import events -from app.api.routes import analysis -from app.api.routes import chat -from app.api.routes import scan -from app.api.routes import keys -from app.api.routes import types -from app.api.routes import custom_types -from app.api.routes import enricher_templates +from app.api.routes import ( + analysis, + auth, + chat, + custom_types, + enricher_templates, + enrichers, + events, + flows, + investigations, + keys, + scan, + sketches, + types, +) # Comma-separated list of allowed origins, e.g. "https://app.example.com,https://staging.example.com" # Falls back to localhost dev origin when unset. Never use "*" with allow_credentials=True. @@ -33,8 +35,8 @@ CORSMiddleware, allow_origins=origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "Accept"], ) @@ -57,5 +59,11 @@ async def health(): app.include_router(scan.router, prefix="/api/scans", tags=["scans"]) app.include_router(keys.router, prefix="/api/keys", tags=["keys"]) app.include_router(types.router, prefix="/api/types", tags=["types"]) -app.include_router(custom_types.router, prefix="/api/custom-types", tags=["custom-types"]) -app.include_router(enricher_templates.router, prefix="/api/enrichers/templates", tags=["enricher-templates"]) +app.include_router( + custom_types.router, prefix="/api/custom-types", tags=["custom-types"] +) +app.include_router( + enricher_templates.router, + prefix="/api/enrichers/templates", + tags=["enricher-templates"], +) diff --git a/flowsint-core/src/flowsint_core/core/auth.py b/flowsint-core/src/flowsint_core/core/auth.py index a0885a814..a643d5067 100644 --- a/flowsint-core/src/flowsint_core/core/auth.py +++ b/flowsint-core/src/flowsint_core/core/auth.py @@ -14,7 +14,7 @@ "AUTH_SECRET environment variable is not set. Please set it in your .env file." ) ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 60 +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") diff --git a/flowsint-core/src/flowsint_core/core/graph/repository.py b/flowsint-core/src/flowsint_core/core/graph/repository.py index d1c93df60..adb472e45 100644 --- a/flowsint-core/src/flowsint_core/core/graph/repository.py +++ b/flowsint-core/src/flowsint_core/core/graph/repository.py @@ -5,12 +5,30 @@ handling raw GraphDict object and operations with batching support. """ +import re from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple from .connection import Neo4jConnection from .types import GraphDict +_SAFE_LABEL_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _sanitize_label(value: str) -> str: + """Validate that a Neo4j label/relationship type is safe for interpolation. + + Only alphanumeric characters and underscores are allowed (must start with a + letter or underscore). This prevents Cypher injection via crafted + node types or relationship labels. + """ + if not value or not _SAFE_LABEL_RE.match(value): + raise ValueError( + f"Invalid Neo4j label: {value!r}. " + "Labels must contain only alphanumeric characters and underscores." + ) + return value + class Neo4jGraphRepository: """ @@ -116,8 +134,10 @@ def _build_node_query( "created_at": datetime.now(timezone.utc).isoformat(), } + safe_node_type = _sanitize_label(node_type) + query = f""" - MERGE (n:{node_type} {{ nodeLabel: $node_label, sketch_id: $sketch_id }}) + MERGE (n:{safe_node_type} {{ nodeLabel: $node_label, sketch_id: $sketch_id }}) ON CREATE SET n.created_at = $created_at SET n += $props SET n.deleted_at = null @@ -144,12 +164,16 @@ def _build_relationship_query( "props": rel_obj, } + safe_from_type = _sanitize_label(from_type) + safe_to_type = _sanitize_label(to_type) + safe_rel_label = _sanitize_label(rel_label) + query = f""" - MATCH (from:{from_type} {{nodeLabel: $from_label, sketch_id: $sketch_id}}) + MATCH (from:{safe_from_type} {{nodeLabel: $from_label, sketch_id: $sketch_id}}) WHERE from.deleted_at IS NULL - MATCH (to:{to_type} {{nodeLabel: $to_label, sketch_id: $sketch_id}}) + MATCH (to:{safe_to_type} {{nodeLabel: $to_label, sketch_id: $sketch_id}}) WHERE to.deleted_at IS NULL - MERGE (from)-[r:{rel_label} {{sketch_id: $sketch_id}}]->(to) + MERGE (from)-[r:{safe_rel_label} {{sketch_id: $sketch_id}}]->(to) SET r += $props SET r.deleted_at = null """ @@ -356,10 +380,12 @@ def batch_create_edges_by_element_id( f"{{{props_str}}}" if props_str else "{sketch_id: $sketch_id}" ) + safe_rel_label = _sanitize_label(rel_label) + query = f""" MATCH (from) WHERE elementId(from) = $from_id_{idx} MATCH (to) WHERE elementId(to) = $to_id_{idx} - MERGE (from)-[r:`{rel_label}` {rel_props}]->(to) + MERGE (from)-[r:`{safe_rel_label}` {rel_props}]->(to) """ # Build params with unique keys for batch execution @@ -631,10 +657,12 @@ def create_relationship_by_element_id( props_str = ", ".join([f"{k}: ${k}" for k in serialized_props.keys()]) rel_props = f"{{{props_str}}}" + safe_rel_label = _sanitize_label(rel_label) + query = f""" MATCH (a) WHERE elementId(a) = $from_id AND a.deleted_at IS NULL MATCH (b) WHERE elementId(b) = $to_id AND b.deleted_at IS NULL - MERGE (a)-[r:`{rel_label}` {rel_props}]->(b) + MERGE (a)-[r:`{safe_rel_label}` {rel_props}]->(b) SET r.deleted_at = null RETURN properties(r) as rel """ @@ -763,8 +791,9 @@ def merge_nodes( params = {"nodeId": new_node_id, "sketch_id": sketch_id, **properties} else: properties["created_at"] = datetime.now(timezone.utc).isoformat() + safe_node_type = _sanitize_label(node_type) create_query = f""" - CREATE (n:`{node_type}`) + CREATE (n:`{safe_node_type}`) SET n = $properties RETURN elementId(n) as newElementId """