fix: secure storage paths and authenticate REST and Ollama servers

- Reject unsafe cross-platform storage names and traversal attempts.
- Confine symlink targets within configured filesystem storage directories.
- Require API keys for every non-loopback server binding.
- Authenticate Ollama routes and securely forward configured credentials.
- Validate chat pattern, context, and session names early.
- Sanitize client errors to hide internal filesystem details.
- Default REST server binding to loopback port 8080.
- Add regression coverage for traversal, symlinks, and authentication.
This commit is contained in:
Kayvan Sylvan 2026-09-02 15:59:59 -07:00
parent cc2a9d39d6
commit f37c71f9f7
34 changed files with 1378 additions and 69 deletions

View file

@ -0,0 +1,7 @@
### PR [#2206](https://github.com/danielmiessler/Fabric/pull/2206) by [ksylvan](https://github.com/ksylvan): fix: confine storage names and authenticate Ollama serve
- Reject unsafe cross-platform storage names and confine symlink targets to configured storage directories.
- Require API keys for non-loopback server bindings, authenticate Ollama routes, and securely forward configured credentials.
- Sanitize client errors to prevent exposure of internal filesystem paths.
- Validate chat pattern, context, and session names early.
- Default the REST API to loopback-only port 8080 and add regression coverage for traversal, symlink, and authentication vulnerabilities.

View file

@ -139,6 +139,15 @@ const docTemplate = `{
"$ref": "#/definitions/fsdb.Pattern"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
@ -346,7 +355,6 @@ const docTemplate = `{
"type": "string"
},
"language": {
"description": "Add Language field to bind from request",
"type": "string"
},
"maxTokens": {
@ -412,9 +420,6 @@ const docTemplate = `{
"type": "number",
"format": "float64"
},
"updateChan": {
"type": "object"
},
"voice": {
"type": "string"
}

View file

@ -133,6 +133,15 @@
"$ref": "#/definitions/fsdb.Pattern"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
@ -340,7 +349,6 @@
"type": "string"
},
"language": {
"description": "Add Language field to bind from request",
"type": "string"
},
"maxTokens": {
@ -406,9 +414,6 @@
"type": "number",
"format": "float64"
},
"updateChan": {
"type": "object"
},
"voice": {
"type": "string"
}

View file

@ -50,7 +50,6 @@ definitions:
imageSize:
type: string
language:
description: Add Language field to bind from request
type: string
maxTokens:
type: integer
@ -95,8 +94,6 @@ definitions:
topP:
format: float64
type: number
updateChan:
type: object
voice:
type: string
type: object
@ -265,6 +262,12 @@ paths:
description: OK
schema:
$ref: '#/definitions/fsdb.Pattern'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:

View file

@ -78,8 +78,8 @@ type Flags struct {
DryRun bool `long:"dry-run" description:"Show what would be sent to the model without actually sending it"`
Serve bool `long:"serve" description:"Serve the Fabric Rest API"`
ServeOllama bool `long:"serveOllama" description:"Serve the Fabric Rest API with ollama endpoints"`
ServeAddress string `long:"address" description:"The address to bind the REST API" default:":8080"`
ServeAPIKey string `long:"api-key" description:"API key used to secure server routes" default:""`
ServeAddress string `long:"address" description:"The address to bind the REST API" default:"127.0.0.1:8080"`
ServeAPIKey string `long:"api-key" env:"FABRIC_API_KEY" description:"API key used to secure server routes" default:""`
Config string `long:"config" description:"Path to YAML config file"`
Version bool `long:"version" description:"Print current version"`
ListExtensions bool `long:"listextensions" description:"List all registered extensions"`

View file

@ -5,6 +5,10 @@ import (
restapi "github.com/danielmiessler/fabric/internal/server"
)
// serveOllama is a seam for tests, because the real entry point blocks
// on a listening socket.
var serveOllama = restapi.ServeOllama
// handleSetupAndServerCommands handles setup and server-related commands
// Returns (handled, error) where handled indicates if a command was processed and should exit
func handleSetupAndServerCommands(currentFlags *Flags, registry *core.PluginRegistry, version string) (handled bool, err error) {
@ -22,7 +26,7 @@ func handleSetupAndServerCommands(currentFlags *Flags, registry *core.PluginRegi
if currentFlags.ServeOllama {
registry.ConfigureVendors()
err = restapi.ServeOllama(registry, currentFlags.ServeAddress, version)
err = serveOllama(registry, currentFlags.ServeAddress, version, currentFlags.ServeAPIKey)
return true, err
}

View file

@ -0,0 +1,37 @@
package cli
import (
"testing"
"github.com/danielmiessler/fabric/internal/core"
"github.com/danielmiessler/fabric/internal/plugins/ai"
)
// The --serveOllama path must pass the address, version, and API key
// flags through to ServeOllama unchanged.
func TestHandleSetupAndServerCommands_ServeOllamaWiring(t *testing.T) {
var gotAddress, gotVersion, gotKey string
prev := serveOllama
serveOllama = func(_ *core.PluginRegistry, address, version, apiKey string) error {
gotAddress, gotVersion, gotKey = address, version, apiKey
return nil
}
defer func() { serveOllama = prev }()
registry := &core.PluginRegistry{
VendorManager: ai.NewVendorsManager(),
VendorsAll: ai.NewVendorsManager(),
}
flags := &Flags{ServeOllama: true, ServeAddress: "127.0.0.1:9999", ServeAPIKey: "secret"}
handled, err := handleSetupAndServerCommands(flags, registry, "v-test")
if err != nil {
t.Fatalf("handleSetupAndServerCommands() error = %v", err)
}
if !handled {
t.Fatal("handleSetupAndServerCommands() handled = false, want true")
}
if gotAddress != "127.0.0.1:9999" || gotVersion != "v-test" || gotKey != "secret" {
t.Fatalf("ServeOllama got (%q, %q, %q), want (127.0.0.1:9999, v-test, secret)",
gotAddress, gotVersion, gotKey)
}
}

View file

@ -375,6 +375,7 @@
"ollama_invalid_data_url_format": "ungültiges Data-URL-Format",
"ollama_invalid_http_timeout_using_default": "ungültiges HTTP-Zeitlimit '%s': %v, verwende Standardwert",
"ollama_invalid_num_ctx_in_request": "Ungültiger num_ctx in Anfrage: %v",
"ollama_invalid_request_body": "ungültiger Anfragetext",
"ollama_no_content_from_upstream": "Kein Inhalt vom Upstream Fabric Server erhalten",
"ollama_num_ctx_exceeds_maximum": "num_ctx überschreitet den maximal zulässigen Wert von %d",
"ollama_num_ctx_invalid_type": "num_ctx muss eine Zahl sein, ungültiger Typ erhalten",
@ -388,6 +389,7 @@
"ollama_sse_buffer_limit": "SSE Zeile überschreitet 1MB Puffer-Limit - Datenzeile zu groß",
"ollama_upstream_non_2xx": "Upstream Fabric Server hat nicht-2xx Status %d zurückgegeben: %s",
"ollama_upstream_non_2xx_body_unreadable": "Upstream Fabric Server hat nicht-2xx Status %d zurückgegeben und Body konnte nicht gelesen werden: %v",
"ollama_upstream_request_failed": "Upstream-Fabric-Server nicht erreichbar",
"ollama_upstream_returned_status": "Upstream Fabric Server hat Status %d zurückgegeben",
"ollama_warning_no_content": "Warnung: Kein Inhalt vom Upstream Fabric Server erhalten",
"ollama_warning_parse_variables": "Warnung: Fehler beim Parsen von options.variables als JSON: %v",
@ -513,10 +515,12 @@
"send_desktop_notification": "Desktop-Benachrichtigung senden, wenn Befehl abgeschlossen ist",
"serve_fabric_api_ollama_endpoints": "Fabric REST API mit ollama-Endpunkten bereitstellen",
"serve_fabric_rest_api": "Fabric REST API bereitstellen",
"server_api_key_required": "Server-Start auf Nicht-Loopback-Adresse %s ohne API-Schlüssel verweigert: Setzen Sie --api-key oder FABRIC_API_KEY, oder binden Sie eine Loopback-Adresse wie 127.0.0.1:8080",
"server_chat_error": "Fehler: %v",
"server_error_marshaling_response": "Fehler beim Serialisieren der Antwort: %v",
"server_error_writing_response": "Fehler beim Schreiben der Antwort: %v",
"server_invalid_request_format": "ungültiges Anfrageformat: %v",
"server_no_api_key_warning": "REST-API-Server wird ohne API-Schlüssel-Authentifizierung gestartet. Dies kann Sicherheitsrisiken bergen.",
"sessions_creating_new": "Erstelle neue Sitzung: %s\n",
"set_debug_level": "Debug-Level festlegen (0=aus, 1=grundlegend, 2=detailliert, 3=Trace, 4=wire)",
"set_frequency_penalty": "Häufigkeitsstrafe festlegen",
@ -620,6 +624,7 @@
"storage_error_save": "%s konnte nicht gespeichert werden: %v",
"storage_error_stat_entry": "Eintrag %s konnte nicht abgefragt werden: %v",
"storage_error_unmarshal": "%s konnte nicht deserialisiert werden: %s",
"storage_invalid_name": "Ungültiger Name: %q",
"strategies_available_header": "Verfügbare Strategien:",
"strategies_cloning_repository": "Repository %s wird geklont (Pfad: %s)...\\n",
"strategies_download_success": "✅ Strategien erfolgreich nach %s heruntergeladen und installiert\\n",

View file

@ -375,6 +375,7 @@
"ollama_invalid_data_url_format": "invalid data URL format",
"ollama_invalid_http_timeout_using_default": "invalid HTTP timeout '%s': %v, using default",
"ollama_invalid_num_ctx_in_request": "invalid num_ctx in request: %v",
"ollama_invalid_request_body": "invalid request body",
"ollama_no_content_from_upstream": "no content received from upstream Fabric server",
"ollama_num_ctx_exceeds_maximum": "num_ctx exceeds maximum allowed value of %d",
"ollama_num_ctx_invalid_type": "num_ctx must be a number, got invalid type",
@ -388,6 +389,7 @@
"ollama_sse_buffer_limit": "SSE line exceeds 1MB buffer limit - data line too large",
"ollama_upstream_non_2xx": "Upstream Fabric server returned non-2xx status %d: %s",
"ollama_upstream_non_2xx_body_unreadable": "Upstream Fabric server returned non-2xx status %d and body could not be read: %v",
"ollama_upstream_request_failed": "failed to reach upstream Fabric server",
"ollama_upstream_returned_status": "upstream Fabric server returned status %d",
"ollama_warning_no_content": "Warning: no content received from upstream Fabric server",
"ollama_warning_parse_variables": "Warning: failed to parse options.variables as JSON: %v",
@ -513,10 +515,12 @@
"send_desktop_notification": "Send desktop notification when command completes",
"serve_fabric_api_ollama_endpoints": "Serve the Fabric Rest API with ollama endpoints",
"serve_fabric_rest_api": "Serve the Fabric Rest API",
"server_api_key_required": "refusing to serve on non-loopback address %s without an API key: set --api-key or FABRIC_API_KEY, or bind a loopback address such as 127.0.0.1:8080",
"server_chat_error": "Error: %v",
"server_error_marshaling_response": "error marshaling response: %v",
"server_error_writing_response": "error writing response: %v",
"server_invalid_request_format": "invalid request format: %v",
"server_no_api_key_warning": "Starting REST API server without API key authentication. This may pose security risks.",
"sessions_creating_new": "Creating new session: %s\n",
"set_debug_level": "Set debug level (0=off, 1=basic, 2=detailed, 3=trace, 4=wire)",
"set_frequency_penalty": "Set frequency penalty",
@ -620,6 +624,7 @@
"storage_error_save": "could not save %s: %v",
"storage_error_stat_entry": "could not stat entry %s: %v",
"storage_error_unmarshal": "could not unmarshal %s: %s",
"storage_invalid_name": "invalid name: %q",
"strategies_available_header": "Available Strategies:",
"strategies_cloning_repository": "Cloning repository %s (path: %s)...\n",
"strategies_download_success": "✅ Successfully downloaded and installed strategies to %s\n",

View file

@ -375,6 +375,7 @@
"ollama_invalid_data_url_format": "formato de URL de datos inválido",
"ollama_invalid_http_timeout_using_default": "Tiempo de espera HTTP inválido '%s': %v, usando el valor predeterminado",
"ollama_invalid_num_ctx_in_request": "num_ctx inválido en la solicitud: %v",
"ollama_invalid_request_body": "cuerpo de solicitud no válido",
"ollama_no_content_from_upstream": "no se recibió contenido del servidor Fabric upstream",
"ollama_num_ctx_exceeds_maximum": "num_ctx excede el valor máximo permitido de %d",
"ollama_num_ctx_invalid_type": "num_ctx debe ser un número, se obtuvo tipo inválido",
@ -388,6 +389,7 @@
"ollama_sse_buffer_limit": "Línea SSE excede el límite de búfer de 1MB - línea de datos demasiado grande",
"ollama_upstream_non_2xx": "El servidor Fabric upstream devolvió estado no-2xx %d: %s",
"ollama_upstream_non_2xx_body_unreadable": "El servidor Fabric upstream devolvió estado no-2xx %d y el cuerpo no se pudo leer: %v",
"ollama_upstream_request_failed": "no se pudo conectar con el servidor Fabric ascendente",
"ollama_upstream_returned_status": "el servidor Fabric upstream devolvió estado %d",
"ollama_warning_no_content": "Advertencia: no se recibió contenido del servidor Fabric upstream",
"ollama_warning_parse_variables": "Advertencia: error al analizar options.variables como JSON: %v",
@ -513,10 +515,12 @@
"send_desktop_notification": "Enviar notificación de escritorio cuando se complete el comando",
"serve_fabric_api_ollama_endpoints": "Servir la API REST de Fabric con endpoints de ollama",
"serve_fabric_rest_api": "Servir la API REST de Fabric",
"server_api_key_required": "se rechaza servir en la dirección no loopback %s sin clave de API: configure --api-key o FABRIC_API_KEY, o use una dirección loopback como 127.0.0.1:8080",
"server_chat_error": "Error: %v",
"server_error_marshaling_response": "error al serializar la respuesta: %v",
"server_error_writing_response": "error al escribir la respuesta: %v",
"server_invalid_request_format": "formato de solicitud no válido: %v",
"server_no_api_key_warning": "Iniciando el servidor de API REST sin autenticación por clave de API. Esto puede suponer riesgos de seguridad.",
"sessions_creating_new": "Creando nueva sesión: %s\n",
"set_debug_level": "Establecer nivel de depuración (0=apagado, 1=básico, 2=detallado, 3=rastreo, 4=wire)",
"set_frequency_penalty": "Establecer penalización de frecuencia",
@ -620,6 +624,7 @@
"storage_error_save": "No se pudo guardar %s: %v",
"storage_error_stat_entry": "No se pudo obtener información de la entrada %s: %v",
"storage_error_unmarshal": "No se pudo deserializar %s: %s",
"storage_invalid_name": "nombre inválido: %q",
"strategies_available_header": "Estrategias disponibles:",
"strategies_cloning_repository": "Clonando el repositorio %s (ruta: %s)...\\n",
"strategies_download_success": "✅ Estrategias descargadas e instaladas correctamente en %s\\n",

View file

@ -375,6 +375,7 @@
"ollama_invalid_data_url_format": "فرمت URL داده نامعتبر",
"ollama_invalid_http_timeout_using_default": "زمان انتظار HTTP نامعتبر '%s': %v، استفاده از مقدار پیش‌فرض",
"ollama_invalid_num_ctx_in_request": "num_ctx نامعتبر در درخواست: %v",
"ollama_invalid_request_body": "بدنه درخواست نامعتبر",
"ollama_no_content_from_upstream": "هیچ محتوایی از سرور Fabric بالادستی دریافت نشد",
"ollama_num_ctx_exceeds_maximum": "num_ctx از حداکثر مقدار مجاز %d فراتر رفته است",
"ollama_num_ctx_invalid_type": "num_ctx باید یک عدد باشد، نوع نامعتبر دریافت شد",
@ -388,6 +389,7 @@
"ollama_sse_buffer_limit": "خط SSE از حد بافر 1MB فراتر رفت - خط داده بیش از حد بزرگ است",
"ollama_upstream_non_2xx": "سرور Fabric بالادستی وضعیت غیر-2xx %d را برگرداند: %s",
"ollama_upstream_non_2xx_body_unreadable": "سرور Fabric بالادستی وضعیت غیر-2xx %d را برگرداند و بدنه قابل خواندن نبود: %v",
"ollama_upstream_request_failed": "دسترسی به سرور بالادستی Fabric ممکن نیست",
"ollama_upstream_returned_status": "سرور Fabric بالادستی وضعیت %d را برگرداند",
"ollama_warning_no_content": "هشدار: هیچ محتوایی از سرور Fabric بالادستی دریافت نشد",
"ollama_warning_parse_variables": "هشدار: شکست در تجزیه options.variables به عنوان JSON: %v",
@ -513,10 +515,12 @@
"send_desktop_notification": "ارسال اعلان دسک‌تاپ هنگام تکمیل دستور",
"serve_fabric_api_ollama_endpoints": "سرویس API REST Fabric با نقاط پایانی ollama",
"serve_fabric_rest_api": "سرویس API REST Fabric",
"server_api_key_required": "سرویس‌دهی روی آدرس غیر loopback %s بدون کلید API رد شد: --api-key یا FABRIC_API_KEY را تنظیم کنید، یا به یک آدرس loopback مانند 127.0.0.1:8080 متصل شوید",
"server_chat_error": "خطا: %v",
"server_error_marshaling_response": "خطا در سریال‌سازی پاسخ: %v",
"server_error_writing_response": "خطا در نوشتن پاسخ: %v",
"server_invalid_request_format": "فرمت درخواست نامعتبر: %v",
"server_no_api_key_warning": "سرور REST API بدون احراز هویت کلید API راه‌اندازی می‌شود. این ممکن است خطرات امنیتی ایجاد کند.",
"sessions_creating_new": "ایجاد نشست جدید: %s\n",
"set_debug_level": "تنظیم سطح اشکال‌زدایی (0=خاموش، 1=پایه، 2=تفصیلی، 3=ردیابی، 4=wire)",
"set_frequency_penalty": "تنظیم جریمه فرکانس",
@ -620,6 +624,7 @@
"storage_error_save": "ذخیره %s ناموفق بود: %v",
"storage_error_stat_entry": "دریافت اطلاعات ورودی %s ناموفق بود: %v",
"storage_error_unmarshal": "بازسریال‌سازی %s ناموفق بود: %s",
"storage_invalid_name": "نام نامعتبر: %q",
"strategies_available_header": "راهبردهای موجود:",
"strategies_cloning_repository": "در حال کلون کردن مخزن %s (مسیر: %s)...\\n",
"strategies_download_success": "✅ راهبردها با موفقیت در %s دانلود و نصب شدند\\n",

View file

@ -375,6 +375,7 @@
"ollama_invalid_data_url_format": "format d'URL de données invalide",
"ollama_invalid_http_timeout_using_default": "Délai d'expiration HTTP invalide '%s' : %v, utilisation de la valeur par défaut",
"ollama_invalid_num_ctx_in_request": "num_ctx invalide dans la requête : %v",
"ollama_invalid_request_body": "corps de requête invalide",
"ollama_no_content_from_upstream": "aucun contenu reçu du serveur Fabric en amont",
"ollama_num_ctx_exceeds_maximum": "num_ctx dépasse la valeur maximale autorisée de %d",
"ollama_num_ctx_invalid_type": "num_ctx doit être un nombre, type invalide reçu",
@ -388,6 +389,7 @@
"ollama_sse_buffer_limit": "La ligne SSE dépasse la limite de tampon de 1 Mo - ligne de données trop grande",
"ollama_upstream_non_2xx": "Le serveur Fabric en amont a renvoyé un statut non-2xx %d : %s",
"ollama_upstream_non_2xx_body_unreadable": "Le serveur Fabric en amont a renvoyé un statut non-2xx %d et le corps n'a pas pu être lu : %v",
"ollama_upstream_request_failed": "impossible de joindre le serveur Fabric en amont",
"ollama_upstream_returned_status": "le serveur Fabric en amont a renvoyé le statut %d",
"ollama_warning_no_content": "Attention : aucun contenu reçu du serveur Fabric en amont",
"ollama_warning_parse_variables": "Attention : échec de l'analyse de options.variables en JSON : %v",
@ -513,10 +515,12 @@
"send_desktop_notification": "Envoyer une notification de bureau quand la commande se termine",
"serve_fabric_api_ollama_endpoints": "Servir l'API REST Fabric avec les endpoints ollama",
"serve_fabric_rest_api": "Servir l'API REST Fabric",
"server_api_key_required": "refus de servir sur l'adresse non loopback %s sans clé API : définissez --api-key ou FABRIC_API_KEY, ou liez une adresse loopback comme 127.0.0.1:8080",
"server_chat_error": "Erreur : %v",
"server_error_marshaling_response": "erreur de sérialisation de la réponse : %v",
"server_error_writing_response": "erreur d'écriture de la réponse : %v",
"server_invalid_request_format": "format de requête invalide : %v",
"server_no_api_key_warning": "Démarrage du serveur API REST sans authentification par clé API. Cela peut présenter des risques de sécurité.",
"sessions_creating_new": "Création d'une nouvelle session : %s\n",
"set_debug_level": "Définir le niveau de débogage (0=désactivé, 1=basique, 2=détaillé, 3=trace, 4=wire)",
"set_frequency_penalty": "Définir la pénalité de fréquence",
@ -620,6 +624,7 @@
"storage_error_save": "Impossible de sauvegarder %s : %v",
"storage_error_stat_entry": "Impossible d'obtenir les informations de l'entrée %s : %v",
"storage_error_unmarshal": "Impossible de désérialiser %s : %s",
"storage_invalid_name": "nom invalide : %q",
"strategies_available_header": "Stratégies disponibles :",
"strategies_cloning_repository": "Clonage du dépôt %s (chemin : %s)...\\n",
"strategies_download_success": "✅ Stratégies téléchargées et installées avec succès dans %s\\n",

View file

@ -375,6 +375,7 @@
"ollama_invalid_data_url_format": "formato URL dati non valido",
"ollama_invalid_http_timeout_using_default": "Timeout HTTP non valido '%s': %v, utilizzo del valore predefinito",
"ollama_invalid_num_ctx_in_request": "num_ctx non valido nella richiesta: %v",
"ollama_invalid_request_body": "corpo della richiesta non valido",
"ollama_no_content_from_upstream": "nessun contenuto ricevuto dal server Fabric upstream",
"ollama_num_ctx_exceeds_maximum": "num_ctx supera il valore massimo consentito di %d",
"ollama_num_ctx_invalid_type": "num_ctx deve essere un numero, ricevuto tipo non valido",
@ -388,6 +389,7 @@
"ollama_sse_buffer_limit": "La riga SSE supera il limite del buffer di 1MB - riga di dati troppo grande",
"ollama_upstream_non_2xx": "Il server Fabric upstream ha restituito stato non-2xx %d: %s",
"ollama_upstream_non_2xx_body_unreadable": "Il server Fabric upstream ha restituito stato non-2xx %d e il corpo non è stato leggibile: %v",
"ollama_upstream_request_failed": "impossibile raggiungere il server Fabric a monte",
"ollama_upstream_returned_status": "il server Fabric upstream ha restituito stato %d",
"ollama_warning_no_content": "Avviso: nessun contenuto ricevuto dal server Fabric upstream",
"ollama_warning_parse_variables": "Avviso: impossibile analizzare options.variables come JSON: %v",
@ -513,10 +515,12 @@
"send_desktop_notification": "Invia notifica desktop quando il comando è completato",
"serve_fabric_api_ollama_endpoints": "Servi l'API REST di Fabric con endpoint ollama",
"serve_fabric_rest_api": "Servi l'API REST di Fabric",
"server_api_key_required": "rifiuto di servire sull'indirizzo non loopback %s senza chiave API: impostare --api-key o FABRIC_API_KEY, oppure associare un indirizzo loopback come 127.0.0.1:8080",
"server_chat_error": "Errore: %v",
"server_error_marshaling_response": "errore nella serializzazione della risposta: %v",
"server_error_writing_response": "errore nella scrittura della risposta: %v",
"server_invalid_request_format": "formato della richiesta non valido: %v",
"server_no_api_key_warning": "Avvio del server API REST senza autenticazione con chiave API. Ciò può comportare rischi per la sicurezza.",
"sessions_creating_new": "Creazione nuova sessione: %s\n",
"set_debug_level": "Imposta livello di debug (0=spento, 1=base, 2=dettagliato, 3=traccia, 4=wire)",
"set_frequency_penalty": "Imposta penalità di frequenza",
@ -620,6 +624,7 @@
"storage_error_save": "Impossibile salvare %s: %v",
"storage_error_stat_entry": "Impossibile ottenere informazioni sulla voce %s: %v",
"storage_error_unmarshal": "Impossibile deserializzare %s: %s",
"storage_invalid_name": "nome non valido: %q",
"strategies_available_header": "Strategie disponibili:",
"strategies_cloning_repository": "Clonazione del repository %s (percorso: %s)...\\n",
"strategies_download_success": "✅ Strategie scaricate e installate correttamente in %s\\n",

View file

@ -375,6 +375,7 @@
"ollama_invalid_data_url_format": "無効なデータURLフォーマット",
"ollama_invalid_http_timeout_using_default": "無効なHTTPタイムアウト '%s': %v、デフォルトを使用します",
"ollama_invalid_num_ctx_in_request": "リクエストに無効な num_ctx があります: %v",
"ollama_invalid_request_body": "無効なリクエスト本文",
"ollama_no_content_from_upstream": "アップストリームの Fabric サーバーからコンテンツを受信しませんでした",
"ollama_num_ctx_exceeds_maximum": "num_ctx が許可される最大値 %d を超えています",
"ollama_num_ctx_invalid_type": "num_ctx は数値である必要があります。無効な型を受け取りました",
@ -388,6 +389,7 @@
"ollama_sse_buffer_limit": "SSE 行が 1MB バッファー制限を超えています - データ行が大きすぎます",
"ollama_upstream_non_2xx": "アップストリームの Fabric サーバーが非 2xx ステータス %d を返しました: %s",
"ollama_upstream_non_2xx_body_unreadable": "アップストリームの Fabric サーバーが非 2xx ステータス %d を返し、ボディを読み取れませんでした: %v",
"ollama_upstream_request_failed": "上流のFabricサーバーに到達できませんでした",
"ollama_upstream_returned_status": "アップストリームの Fabric サーバーがステータス %d を返しました",
"ollama_warning_no_content": "警告: アップストリームの Fabric サーバーからコンテンツを受信しませんでした",
"ollama_warning_parse_variables": "警告: options.variables を JSON として解析できませんでした: %v",
@ -513,10 +515,12 @@
"send_desktop_notification": "コマンド完了時にデスクトップ通知を送信",
"serve_fabric_api_ollama_endpoints": "ollamaエンドポイント付きのFabric REST APIを提供",
"serve_fabric_rest_api": "Fabric REST APIを提供",
"server_api_key_required": "APIキーなしで非ループバックアドレス %s での提供を拒否しました。--api-key または FABRIC_API_KEY を設定するか、127.0.0.1:8080 のようなループバックアドレスにバインドしてください",
"server_chat_error": "エラー: %v",
"server_error_marshaling_response": "レスポンスのシリアライズエラー: %v",
"server_error_writing_response": "レスポンスの書き込みエラー: %v",
"server_invalid_request_format": "無効なリクエスト形式: %v",
"server_no_api_key_warning": "APIキー認証なしでREST APIサーバーを起動しています。セキュリティ上のリスクが生じる可能性があります。",
"sessions_creating_new": "新しいセッションを作成中: %s\n",
"set_debug_level": "デバッグレベルを設定0=オフ、1=基本、2=詳細、3=トレース、4=wire",
"set_frequency_penalty": "頻度ペナルティを設定",
@ -620,6 +624,7 @@
"storage_error_save": "%sを保存できませんでした: %v",
"storage_error_stat_entry": "エントリ%sの情報を取得できませんでした: %v",
"storage_error_unmarshal": "%sをデシリアライズできませんでした: %s",
"storage_invalid_name": "無効な名前: %q",
"strategies_available_header": "利用可能な戦略:",
"strategies_cloning_repository": "リポジトリ %s をクローン中 (パス: %s)...\\n",
"strategies_download_success": "✅ 戦略を %s に正常にダウンロードしてインストールしました\\n",

View file

@ -375,6 +375,7 @@
"ollama_invalid_data_url_format": "nieprawidłowy format data URL",
"ollama_invalid_http_timeout_using_default": "nieprawidłowy limit czasu HTTP '%s': %v, używam domyślnego",
"ollama_invalid_num_ctx_in_request": "nieprawidłowa wartość num_ctx w żądaniu: %v",
"ollama_invalid_request_body": "nieprawidłowa treść żądania",
"ollama_no_content_from_upstream": "nie odebrano zawartości z upstream serwera fabric",
"ollama_num_ctx_exceeds_maximum": "num_ctx przekracza maksymalną dozwoloną wartość %d",
"ollama_num_ctx_invalid_type": "num_ctx musi być liczbą, podano nieprawidłowy typ",
@ -388,6 +389,7 @@
"ollama_sse_buffer_limit": "Linia SSE przekracza limit bufora 1MB - linia danych zbyt duża",
"ollama_upstream_non_2xx": "Upstream serwer fabric zwrócił status inny niż 2xx %d: %s",
"ollama_upstream_non_2xx_body_unreadable": "Upstream serwer fabric zwrócił status inny niż 2xx %d i nie można odczytać treści: %v",
"ollama_upstream_request_failed": "nie można połączyć się z serwerem nadrzędnym Fabric",
"ollama_upstream_returned_status": "upstream serwer fabric zwrócił status %d",
"ollama_warning_no_content": "Ostrzeżenie: nie odebrano zawartości z upstream serwera fabric",
"ollama_warning_parse_variables": "Ostrzeżenie: nie udało się przetworzyć options.variables jako JSON: %v",
@ -513,10 +515,12 @@
"send_desktop_notification": "Wyślij powiadomienie pulpitu po zakończeniu polecenia",
"serve_fabric_api_ollama_endpoints": "Uruchom fabric Rest API z endpointami ollama",
"serve_fabric_rest_api": "Uruchom fabric Rest API",
"server_api_key_required": "odmowa udostępniania na adresie %s spoza pętli zwrotnej bez klucza API: ustaw --api-key lub FABRIC_API_KEY, albo powiąż adres pętli zwrotnej, np. 127.0.0.1:8080",
"server_chat_error": "Błąd: %v",
"server_error_marshaling_response": "błąd podczas serializacji odpowiedzi: %v",
"server_error_writing_response": "błąd podczas zapisywania odpowiedzi: %v",
"server_invalid_request_format": "nieprawidłowy format żądania: %v",
"server_no_api_key_warning": "Uruchamianie serwera REST API bez uwierzytelniania kluczem API. Może to stwarzać zagrożenia bezpieczeństwa.",
"sessions_creating_new": "Tworzenie nowej sesji: %s\n",
"set_debug_level": "Ustaw poziom debugowania (0=wyłączone, 1=podstawowe, 2=szczegółowe, 3=śledzenie, 4=surowe)",
"set_frequency_penalty": "Ustaw karę częstotliwości",
@ -620,6 +624,7 @@
"storage_error_save": "nie można zapisać %s: %v",
"storage_error_stat_entry": "nie można pobrać informacji o wpisie %s: %v",
"storage_error_unmarshal": "nie można deserializować %s: %s",
"storage_invalid_name": "nieprawidłowa nazwa: %q",
"strategies_available_header": "Dostępne strategie:",
"strategies_cloning_repository": "Klonowanie repozytorium %s (ścieżka: %s)...\n",
"strategies_download_success": "✅ Pomyślnie pobrano i zainstalowano strategie w %s\n",

View file

@ -375,6 +375,7 @@
"ollama_invalid_data_url_format": "formato de URL de dados inválido",
"ollama_invalid_http_timeout_using_default": "Tempo limite HTTP inválido '%s': %v, usando o padrão",
"ollama_invalid_num_ctx_in_request": "num_ctx inválido na requisição: %v",
"ollama_invalid_request_body": "corpo de solicitação inválido",
"ollama_no_content_from_upstream": "nenhum conteúdo recebido do servidor Fabric upstream",
"ollama_num_ctx_exceeds_maximum": "num_ctx excede o valor máximo permitido de %d",
"ollama_num_ctx_invalid_type": "num_ctx deve ser um número, recebeu tipo inválido",
@ -388,6 +389,7 @@
"ollama_sse_buffer_limit": "Linha SSE excede limite de buffer de 1MB - linha de dados muito grande",
"ollama_upstream_non_2xx": "Servidor Fabric upstream retornou status não-2xx %d: %s",
"ollama_upstream_non_2xx_body_unreadable": "Servidor Fabric upstream retornou status não-2xx %d e o corpo não pôde ser lido: %v",
"ollama_upstream_request_failed": "falha ao conectar ao servidor Fabric upstream",
"ollama_upstream_returned_status": "servidor Fabric upstream retornou status %d",
"ollama_warning_no_content": "Aviso: nenhum conteúdo recebido do servidor Fabric upstream",
"ollama_warning_parse_variables": "Aviso: falha ao analisar options.variables como JSON: %v",
@ -513,10 +515,12 @@
"send_desktop_notification": "Enviar notificação desktop quando o comando for concluído",
"serve_fabric_api_ollama_endpoints": "Servir a API REST do Fabric com endpoints ollama",
"serve_fabric_rest_api": "Servir a API REST do Fabric",
"server_api_key_required": "recusando servir no endereço não loopback %s sem chave de API: defina --api-key ou FABRIC_API_KEY, ou vincule um endereço loopback como 127.0.0.1:8080",
"server_chat_error": "Erro: %v",
"server_error_marshaling_response": "erro ao serializar resposta: %v",
"server_error_writing_response": "erro ao escrever resposta: %v",
"server_invalid_request_format": "formato de solicitação inválido: %v",
"server_no_api_key_warning": "Iniciando o servidor da API REST sem autenticação por chave de API. Isso pode representar riscos de segurança.",
"sessions_creating_new": "Criando nova sessão: %s\n",
"set_debug_level": "Definir nível de debug (0=desligado, 1=básico, 2=detalhado, 3=rastreamento, 4=wire)",
"set_frequency_penalty": "Definir penalidade de frequência",
@ -620,6 +624,7 @@
"storage_error_save": "Não foi possível salvar %s: %v",
"storage_error_stat_entry": "Não foi possível obter informações da entrada %s: %v",
"storage_error_unmarshal": "Não foi possível desserializar %s: %s",
"storage_invalid_name": "nome inválido: %q",
"strategies_available_header": "Estratégias disponíveis:",
"strategies_cloning_repository": "Clonando repositório %s (caminho: %s)...\\n",
"strategies_download_success": "✅ Estratégias baixadas e instaladas com sucesso em %s\\n",

View file

@ -375,6 +375,7 @@
"ollama_invalid_data_url_format": "formato de URL de dados inválido",
"ollama_invalid_http_timeout_using_default": "Tempo limite HTTP inválido '%s': %v, a utilizar o padrão",
"ollama_invalid_num_ctx_in_request": "num_ctx inválido no pedido: %v",
"ollama_invalid_request_body": "corpo de pedido inválido",
"ollama_no_content_from_upstream": "nenhum conteúdo recebido do servidor Fabric upstream",
"ollama_num_ctx_exceeds_maximum": "num_ctx excede o valor máximo permitido de %d",
"ollama_num_ctx_invalid_type": "num_ctx deve ser um número, recebeu tipo inválido",
@ -388,6 +389,7 @@
"ollama_sse_buffer_limit": "Linha SSE excede limite de buffer de 1MB - linha de dados demasiado grande",
"ollama_upstream_non_2xx": "Servidor Fabric upstream retornou estado não-2xx %d: %s",
"ollama_upstream_non_2xx_body_unreadable": "Servidor Fabric upstream retornou estado não-2xx %d e o corpo não pôde ser lido: %v",
"ollama_upstream_request_failed": "falha ao contactar o servidor Fabric a montante",
"ollama_upstream_returned_status": "servidor Fabric upstream retornou estado %d",
"ollama_warning_no_content": "Aviso: nenhum conteúdo recebido do servidor Fabric upstream",
"ollama_warning_parse_variables": "Aviso: falha ao analisar options.variables como JSON: %v",
@ -513,10 +515,12 @@
"send_desktop_notification": "Enviar notificação no ambiente de trabalho quando o comando for concluído",
"serve_fabric_api_ollama_endpoints": "Servir a API REST do Fabric com endpoints ollama",
"serve_fabric_rest_api": "Servir a API REST do Fabric",
"server_api_key_required": "recusa de servir no endereço não loopback %s sem chave de API: defina --api-key ou FABRIC_API_KEY, ou vincule um endereço loopback como 127.0.0.1:8080",
"server_chat_error": "Erro: %v",
"server_error_marshaling_response": "erro ao serializar resposta: %v",
"server_error_writing_response": "erro ao escrever resposta: %v",
"server_invalid_request_format": "formato de pedido inválido: %v",
"server_no_api_key_warning": "A iniciar o servidor da API REST sem autenticação por chave de API. Isto pode representar riscos de segurança.",
"sessions_creating_new": "A criar nova sessão: %s\n",
"set_debug_level": "Definir nível de debug (0=desligado, 1=básico, 2=detalhado, 3=rastreio, 4=wire)",
"set_frequency_penalty": "Definir penalidade de frequência",
@ -620,6 +624,7 @@
"storage_error_save": "Não foi possível guardar %s: %v",
"storage_error_stat_entry": "Não foi possível obter informações da entrada %s: %v",
"storage_error_unmarshal": "Não foi possível desserializar %s: %s",
"storage_invalid_name": "nome inválido: %q",
"strategies_available_header": "Estratégias disponíveis:",
"strategies_cloning_repository": "A clonar repositório %s (caminho: %s)...\\n",
"strategies_download_success": "✅ Estratégias transferidas e instaladas com sucesso em %s\\n",

View file

@ -375,6 +375,7 @@
"ollama_invalid_data_url_format": "无效的数据 URL 格式",
"ollama_invalid_http_timeout_using_default": "无效的 HTTP 超时时间 '%s'%v使用默认值",
"ollama_invalid_num_ctx_in_request": "请求中的 num_ctx 无效:%v",
"ollama_invalid_request_body": "无效的请求正文",
"ollama_no_content_from_upstream": "未从上游 Fabric 服务器收到内容",
"ollama_num_ctx_exceeds_maximum": "num_ctx 超过允许的最大值 %d",
"ollama_num_ctx_invalid_type": "num_ctx 必须是数字,收到无效类型",
@ -388,6 +389,7 @@
"ollama_sse_buffer_limit": "SSE 行超过 1MB 缓冲区限制 - 数据行过大",
"ollama_upstream_non_2xx": "上游 Fabric 服务器返回非 2xx 状态 %d%s",
"ollama_upstream_non_2xx_body_unreadable": "上游 Fabric 服务器返回非 2xx 状态 %d 且无法读取正文:%v",
"ollama_upstream_request_failed": "无法连接上游 Fabric 服务器",
"ollama_upstream_returned_status": "上游 Fabric 服务器返回状态 %d",
"ollama_warning_no_content": "警告:未从上游 Fabric 服务器收到内容",
"ollama_warning_parse_variables": "警告:无法将 options.variables 解析为 JSON%v",
@ -513,10 +515,12 @@
"send_desktop_notification": "命令完成时发送桌面通知",
"serve_fabric_api_ollama_endpoints": "提供带有 ollama 端点的 Fabric REST API 服务",
"serve_fabric_rest_api": "提供 Fabric REST API 服务",
"server_api_key_required": "拒绝在非回环地址 %s 上提供服务(未配置 API 密钥):请设置 --api-key 或 FABRIC_API_KEY或绑定回环地址如 127.0.0.1:8080",
"server_chat_error": "错误:%v",
"server_error_marshaling_response": "序列化响应错误:%v",
"server_error_writing_response": "写入响应错误:%v",
"server_invalid_request_format": "无效的请求格式:%v",
"server_no_api_key_warning": "正在启动 REST API 服务器,未启用 API 密钥身份验证。这可能带来安全风险。",
"sessions_creating_new": "正在创建新会话:%s\n",
"set_debug_level": "设置调试级别0=关闭1=基本2=详细3=跟踪4=wire",
"set_frequency_penalty": "设置频率惩罚",
@ -620,6 +624,7 @@
"storage_error_save": "无法保存 %s%v",
"storage_error_stat_entry": "无法获取条目 %s 的信息:%v",
"storage_error_unmarshal": "无法反序列化 %s%s",
"storage_invalid_name": "无效的名称:%q",
"strategies_available_header": "可用的策略:",
"strategies_cloning_repository": "正在克隆仓库 %s至路径%s...\\n",
"strategies_download_success": "✅ 已成功下载并安装策略到 %s\\n",

View file

@ -1,10 +1,15 @@
package db
// Storage is the contract for a named-entity store. Each implementation
// specifies the names that are valid. It rejects an invalid name with a
// typed error. Callers can map this error to a client error.
type Storage[T any] interface {
Configure() (err error)
Get(name string) (ret *T, err error)
GetNames() (ret []string, err error)
Delete(name string) (err error)
// Exists reports false for an invalid name. It cannot show the
// difference between a rejected name and an absent entry.
Exists(name string) (ret bool)
Rename(oldName, newName string) (err error)
Save(name string, content []byte) (err error)

View file

@ -55,14 +55,18 @@ func (o *PatternsEntity) GetRaw(name string) (*Pattern, error) {
return o.getFromDB(name)
}
func (o *PatternsEntity) loadPattern(source string) (pattern *Pattern, err error) {
// Determine if this is a file path
isFilePath := strings.HasPrefix(source, "\\") ||
// LooksLikePatternFilePath reports whether loadPattern uses source as a
// filesystem path. HTTP handlers must reject these names to keep the
// CLI file-path feature out of the REST API.
func LooksLikePatternFilePath(source string) bool {
return strings.HasPrefix(source, "\\") ||
strings.HasPrefix(source, "/") ||
strings.HasPrefix(source, "~") ||
strings.HasPrefix(source, ".")
}
if isFilePath {
func (o *PatternsEntity) loadPattern(source string) (pattern *Pattern, err error) {
if LooksLikePatternFilePath(source) {
// Resolve the file path using GetAbsolutePath
var absPath string
if absPath, err = util.GetAbsolutePath(source); err != nil {
@ -119,8 +123,13 @@ func (o *PatternsEntity) applyVariables(
// retrieves a pattern from the database by name
func (o *PatternsEntity) getFromDB(name string) (ret *Pattern, err error) {
if strings.Contains(name, "..") {
return nil, fmt.Errorf(i18n.T("pattern_invalid_name"), name)
if ValidateStorageName(name) != nil {
// The typed error lets an HTTP route without a pre-validation
// guard map this rejection to 400, not 500.
return nil, &InvalidStorageNameError{
Name: name,
Message: fmt.Sprintf(i18n.T("pattern_invalid_name"), name),
}
}
// First check custom patterns directory if it exists
@ -283,13 +292,45 @@ func (o *PatternsEntity) Get(name string) (*Pattern, error) {
return o.GetApplyVariables(name, nil, "")
}
func (o *PatternsEntity) Save(name string, content []byte) (err error) {
patternDir := filepath.Join(o.Dir, name)
// Do not store a name that loadPattern uses as a file path, for
// example ".foo" or "~bar". For such a name, GetApplyVariables reads
// from the filesystem, not from the database.
if LooksLikePatternFilePath(name) {
return &InvalidStorageNameError{
Name: name,
Message: fmt.Sprintf(i18n.T("pattern_invalid_name"), name),
}
}
var patternDir string
if patternDir, err = o.resolvedPath(name); err != nil {
return
}
if err = os.MkdirAll(patternDir, os.ModePerm); err != nil {
return fmt.Errorf(i18n.T("patterns_error_create_directory"), err)
}
patternPath := filepath.Join(patternDir, o.SystemPatternFile)
// The pattern file can be a symlink that already exists. Do not
// write through a symlink that goes out of the pattern directory.
if err = symlinkContained(patternDir, patternPath, name); err != nil {
return err
}
if err = os.WriteFile(patternPath, content, 0644); err != nil {
return fmt.Errorf(i18n.T("patterns_error_save_pattern"), err)
}
return nil
}
// Rename applies the file-path guard from Save to the destination name.
// Without the guard, the inherited StorageEntity.Rename accepts ".foo"
// or "~foo", and loadPattern then reads these names from the
// filesystem. A path-like source stays permitted, which lets you rename
// a legacy entry to a valid name.
func (o *PatternsEntity) Rename(oldName, newName string) error {
if LooksLikePatternFilePath(newName) {
return &InvalidStorageNameError{
Name: newName,
Message: fmt.Sprintf(i18n.T("pattern_invalid_name"), newName),
}
}
return o.StorageEntity.Rename(oldName, newName)
}

View file

@ -3,8 +3,10 @@ package fsdb
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/danielmiessler/fabric/internal/i18n"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -179,6 +181,88 @@ func TestPatternsEntity_Save(t *testing.T) {
assert.Equal(t, content, data)
}
// Save must reject a name that loadPattern uses as a filesystem path,
// and that includes a traversal name. For such a name,
// GetApplyVariables reads from the disk, not from the database.
func TestPatternsEntity_SaveRejectsFilePathNames(t *testing.T) {
entity, cleanup := setupTestPatternsEntity(t)
defer cleanup()
for _, name := range []string{"..", ".foo", "..bar", "~bar", "/abs/path", `\win\path`} {
err := entity.Save(name, []byte("pwned"))
assert.Error(t, err, "expected error for file-path name: %q", name)
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) {
// If Save accepts an absolute name, it does not write in
// entity.Dir, and the join below points to the incorrect
// location. For these two names, only the error assertion
// gives protection.
continue
}
// For ".." this path is the system.md of the parent directory,
// which Save makes if it accepts a traversal name.
_, statErr := os.Stat(filepath.Join(entity.Dir, name, entity.SystemPatternFile))
assert.True(t, os.IsNotExist(statErr), "wrote a pattern file for: %q", name)
}
}
// Rename must reject a file-path-like destination, the same as Save.
// The inherited StorageEntity.Rename accepts such a destination.
func TestPatternsEntity_RenameRejectsFilePathDestination(t *testing.T) {
entity, cleanup := setupTestPatternsEntity(t)
defer cleanup()
createTestPattern(t, entity, "good-name", "content")
for _, newName := range []string{".foo", "~bar"} {
err := entity.Rename("good-name", newName)
var invalidName *InvalidStorageNameError
require.ErrorAs(t, err, &invalidName, "expected rejection for destination: %q", newName)
_, statErr := os.Stat(filepath.Join(entity.Dir, newName))
assert.True(t, os.IsNotExist(statErr), "renamed to: %q", newName)
}
// A valid destination still works.
require.NoError(t, entity.Rename("good-name", "better-name"))
_, err := os.Stat(filepath.Join(entity.Dir, "better-name"))
require.NoError(t, err)
}
// Save must not write through a symlinked pattern directory or a
// symlinked pattern file that points outside the storage tree.
func TestPatternsEntity_SaveRejectsSymlinkEscape(t *testing.T) {
entity, cleanup := setupTestPatternsEntity(t)
defer cleanup()
outsideDir := t.TempDir()
mustSymlink(t, outsideDir, filepath.Join(entity.Dir, "linked-dir"))
err := entity.Save("linked-dir", []byte("pwned"))
var invalidName *InvalidStorageNameError
require.ErrorAs(t, err, &invalidName)
_, statErr := os.Stat(filepath.Join(outsideDir, entity.SystemPatternFile))
assert.True(t, os.IsNotExist(statErr), "wrote through the symlinked pattern dir")
outsideFile := filepath.Join(outsideDir, "target.md")
require.NoError(t, os.WriteFile(outsideFile, []byte("keep"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(entity.Dir, "real-pattern"), 0o755))
mustSymlink(t, outsideFile, filepath.Join(entity.Dir, "real-pattern", entity.SystemPatternFile))
err = entity.Save("real-pattern", []byte("pwned"))
require.ErrorAs(t, err, &invalidName)
got, readErr := os.ReadFile(outsideFile)
require.NoError(t, readErr)
assert.Equal(t, "keep", string(got), "outside pattern file was overwritten")
}
func TestGetApplyVariables_FromFile(t *testing.T) {
entity, cleanup := setupTestPatternsEntity(t)
defer cleanup()
path := filepath.Join(t.TempDir(), "fromfile.md")
require.NoError(t, os.WriteFile(path, []byte("Hello {{input}}"), 0o644))
result, err := entity.GetApplyVariables(path, nil, "world")
require.NoError(t, err)
assert.Equal(t, "Hello world", result.Pattern)
}
func TestPatternsEntity_CustomPatterns(t *testing.T) {
// Create main patterns directory
mainDir, err := os.MkdirTemp("", "test-main-patterns-*")
@ -333,20 +417,43 @@ func TestPrintPattern(t *testing.T) {
}
func TestGetFromDB_PathTraversal(t *testing.T) {
if _, err := i18n.Init("en"); err != nil {
t.Fatalf("i18n.Init() error = %v", err)
}
entity, cleanup := setupTestPatternsEntity(t)
defer cleanup()
traversalNames := []string{
"../etc/passwd",
"../../secret",
"foo/../bar",
"..",
"valid/../../../etc/shadow",
for _, name := range invalidStorageNames {
t.Run(name, func(t *testing.T) {
_, err := entity.GetRaw(name)
require.Error(t, err, "expected error for traversal name: %q", name)
assert.Contains(t, err.Error(), "invalid pattern name", "wrong error for: %q", name)
var invalidName *InvalidStorageNameError
assert.ErrorAs(t, err, &invalidName, "want typed rejection for: %q", name)
})
}
for _, name := range traversalNames {
_, err := entity.GetRaw(name)
assert.Error(t, err, "expected error for traversal name: %q", name)
assert.Contains(t, err.Error(), "invalid pattern name", "wrong error for: %q", name)
}
// A ".." in a name without separators is one safe path element.
// ValidateStorageName accepts it, and getFromDB also accepts it.
func TestGetFromDB_AllowsDotsWithinName(t *testing.T) {
entity, cleanup := setupTestPatternsEntity(t)
defer cleanup()
createTestPattern(t, entity, "foo..bar", "dotty {{input}}")
pattern, err := entity.GetRaw("foo..bar")
require.NoError(t, err)
assert.Equal(t, "dotty {{input}}", pattern.Pattern)
}
func TestLooksLikePatternFilePath(t *testing.T) {
for _, source := range []string{"/x", `~\x`, `\x`, `.\x`, "~", ".", ".."} {
assert.True(t, LooksLikePatternFilePath(source), "expected file-path detection for: %q", source)
}
for _, source := range []string{"", "pattern", "foo..bar", "a/b", "x~y", "x.y"} {
assert.False(t, LooksLikePatternFilePath(source), "unexpected file-path detection for: %q", source)
}
}

View file

@ -13,6 +13,12 @@ type SessionsEntity struct {
}
func (o *SessionsEntity) Get(name string) (session *Session, err error) {
// Reject invalid names here. Exists reports false for them, and the
// missing-session branch then answers with a new empty session and
// no error.
if err = ValidateStorageName(name); err != nil {
return nil, err
}
session = &Session{Name: name}
if o.Exists(name) {

View file

@ -21,6 +21,20 @@ func TestSessions_GetOrCreateSession(t *testing.T) {
}
}
// Get must reject an invalid name and must not answer with a new empty
// session. GET /sessions/<bad-name> is then a 400, the same as for the
// other entities.
func TestSessions_GetRejectsInvalidNames(t *testing.T) {
sessions := &SessionsEntity{
StorageEntity: &StorageEntity{Dir: t.TempDir(), FileExtension: ".json"},
}
for _, name := range invalidStorageNames {
if _, err := sessions.Get(name); err == nil {
t.Errorf("Get(%q) succeeded, want error", name)
}
}
}
func TestSessions_SaveSession(t *testing.T) {
dir := t.TempDir()
sessions := &SessionsEntity{

View file

@ -11,6 +11,10 @@ import (
"github.com/danielmiessler/fabric/internal/util"
)
// StorageEntity is the filesystem-backed db.Storage implementation.
// Each method that gets a name requires one that obeys
// ValidateStorageName. It rejects other names with
// *InvalidStorageNameError, which HTTP handlers map to 400.
type StorageEntity struct {
Label string
Dir string
@ -68,34 +72,57 @@ func (o *StorageEntity) GetNames() (ret []string, err error) {
}
func (o *StorageEntity) Delete(name string) (err error) {
if err = os.RemoveAll(o.BuildFilePathByName(name)); err != nil {
var path string
if path, err = o.resolvedPath(name); err != nil {
return
}
if err = os.RemoveAll(path); err != nil {
err = fmt.Errorf(i18n.T("storage_error_delete"), name, err)
}
return
}
func (o *StorageEntity) Exists(name string) (ret bool) {
_, err := os.Stat(o.BuildFilePathByName(name))
path, err := o.resolvedPath(name)
if err != nil {
return false
}
_, err = os.Stat(path)
ret = !os.IsNotExist(err)
return
}
func (o *StorageEntity) Rename(oldName, newName string) (err error) {
if err = os.Rename(o.BuildFilePathByName(oldName), o.BuildFilePathByName(newName)); err != nil {
var oldPath, newPath string
if oldPath, err = o.resolvedPath(oldName); err != nil {
return
}
if newPath, err = o.resolvedPath(newName); err != nil {
return
}
if err = os.Rename(oldPath, newPath); err != nil {
err = fmt.Errorf(i18n.T("storage_error_rename"), oldName, newName, err)
}
return
}
func (o *StorageEntity) Save(name string, content []byte) (err error) {
if err = os.WriteFile(o.BuildFilePathByName(name), content, 0644); err != nil {
var path string
if path, err = o.resolvedPath(name); err != nil {
return
}
if err = os.WriteFile(path, content, 0644); err != nil {
err = fmt.Errorf(i18n.T("storage_error_save"), name, err)
}
return
}
func (o *StorageEntity) Load(name string) (ret []byte, err error) {
if ret, err = os.ReadFile(o.BuildFilePathByName(name)); err != nil {
var path string
if path, err = o.resolvedPath(name); err != nil {
return
}
if ret, err = os.ReadFile(path); err != nil {
err = fmt.Errorf(i18n.T("storage_error_load"), name, err)
}
return
@ -120,11 +147,6 @@ func (o *StorageEntity) ListNames(shellCompleteList bool) (err error) {
return
}
func (o *StorageEntity) BuildFilePathByName(name string) (ret string) {
ret = o.BuildFilePath(o.buildFileName(name))
return
}
func (o *StorageEntity) BuildFilePath(fileName string) (ret string) {
ret = filepath.Join(o.Dir, fileName)
return
@ -134,6 +156,122 @@ func (o *StorageEntity) buildFileName(name string) string {
return fmt.Sprintf("%s%v", name, o.FileExtension)
}
// InvalidStorageNameError reports a name that storage-name validation
// rejected. HTTP handlers map it to 400 Bad Request. All other storage
// errors stay 500 errors.
type InvalidStorageNameError struct {
Name string
Message string // optional: the default is the storage_invalid_name translation
}
func (e *InvalidStorageNameError) Error() string {
if e.Message != "" {
return e.Message
}
return fmt.Sprintf(i18n.T("storage_invalid_name"), e.Name)
}
// windowsReservedNames are DOS device names. On Windows, these names
// identify devices, not files. The match ignores case and all text after
// the first dot, because Windows maps "CON.tar.gz" to the CON device.
var windowsReservedNames = map[string]bool{
"CON": true, "PRN": true, "AUX": true, "NUL": true,
"COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true,
"COM6": true, "COM7": true, "COM8": true, "COM9": true,
"LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true,
"LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
}
// ValidateStorageName rejects an empty name, ".", "..", and each name
// that is not a single path element. It also rejects names that are
// dangerous only on Windows: names with ":" (an NTFS alternate data
// stream suffix), names with a dot or space at the end, and reserved
// DOS device names. Windows removes a dot or space at the end, and the
// shortened name then collides with an existing entry. The policy is
// the same on each platform, and an entry made on one system stays
// valid on the other systems. A Unix
// entry that already has a name against these rules shows in GetNames
// but is not accessible. To repair it, rename its file or directory on
// disk. Call this function before you join a name to a storage
// directory.
func ValidateStorageName(name string) error {
if name == "" || name == "." || name == ".." {
return &InvalidStorageNameError{Name: name}
}
if strings.ContainsAny(name, `/\:`) {
return &InvalidStorageNameError{Name: name}
}
if name != strings.TrimRight(name, ". ") {
return &InvalidStorageNameError{Name: name}
}
base := strings.ToUpper(name)
if i := strings.IndexByte(base, '.'); i >= 0 {
base = base[:i]
}
if windowsReservedNames[base] {
return &InvalidStorageNameError{Name: name}
}
return nil
}
// symlinkContained rejects an entry at path if the entry resolves,
// through symlinks, to a target outside absDir. A missing entry passes,
// because the lexical check in resolvedPath already keeps the path that
// a write will make in the directory. The two inputs must be absolute
// paths. If they are not, you cannot compare the resolved forms. The
// check does not fully prevent local races. If a hostile local writer
// enters the threat model, move to os.Root.
func symlinkContained(absDir, path, name string) error {
target, err := filepath.EvalSymlinks(path)
if err != nil {
if os.IsNotExist(err) {
if _, lerr := os.Lstat(path); os.IsNotExist(lerr) {
return nil
}
// This is a dangling symlink. A write through it makes the
// outside target.
return &InvalidStorageNameError{Name: name}
}
return err
}
resolvedDir, err := filepath.EvalSymlinks(absDir)
if err != nil {
return err
}
rel, err := filepath.Rel(resolvedDir, target)
if err != nil || !filepath.IsLocal(rel) {
return &InvalidStorageNameError{Name: name}
}
return nil
}
// resolvedPath keeps name in the entity directory. It validates the
// name, checks containment again after absolute resolution, and
// rejects a symlinked entry that resolves out of the directory.
// Symlinks that stay in the directory are permitted. A storage
// directory that is a symlink is also permitted.
func (o *StorageEntity) resolvedPath(name string) (string, error) {
if err := ValidateStorageName(name); err != nil {
return "", err
}
absDir, err := filepath.Abs(o.Dir)
if err != nil {
return "", err
}
absFull, err := filepath.Abs(filepath.Join(o.Dir, o.buildFileName(name)))
if err != nil {
return "", err
}
rel, err := filepath.Rel(absDir, absFull)
if err != nil || !filepath.IsLocal(rel) {
return "", &InvalidStorageNameError{Name: name}
}
if err := symlinkContained(absDir, absFull, name); err != nil {
return "", err
}
return absFull, nil
}
func (o *StorageEntity) SaveAsJson(name string, item any) (err error) {
var jsonString []byte
if jsonString, err = json.Marshal(item); err == nil {

View file

@ -1,7 +1,11 @@
package fsdb
import (
"os"
"path/filepath"
"testing"
"github.com/danielmiessler/fabric/internal/i18n"
)
func TestStorage_SaveAndLoad(t *testing.T) {
@ -50,3 +54,197 @@ func TestStorage_Delete(t *testing.T) {
t.Errorf("expected file to be deleted")
}
}
// invalidStorageNames are names that ValidateStorageName must reject on
// each platform. The storage tests and the pattern traversal tests
// share this list, and one new attack name gets a test at each
// location. The backslash cases guard the `\` half of the separator
// check. That half is the Windows-only escape guard that a "simplify
// to filepath.Base" refactor removes without a test failure. The colon,
// reserved-name, and trailing dot and space cases guard the Windows
// protections: NTFS alternate data streams, DOS device names, and name
// suffixes that Windows removes.
var invalidStorageNames = []string{
"..", "../keep.txt", "/etc/passwd", "foo/../../keep.txt", ".", "",
`foo\bar`, `..\x`,
"foo:bar", "NUL", "con.txt", "CON.tar.gz", "foo.", "foo ",
}
// newTraversalFixture returns a storage entity in a temporary root and
// a marker file out of the entity directory. It also returns a check
// that fails the test if the marker or the entity directory is gone.
func newTraversalFixture(t *testing.T) (storage *StorageEntity, checkSurvived func()) {
t.Helper()
root := t.TempDir()
dir := filepath.Join(root, "contexts")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
marker := filepath.Join(root, "keep.txt")
if err := os.WriteFile(marker, []byte("keep"), 0o644); err != nil {
t.Fatal(err)
}
storage = &StorageEntity{Dir: dir, Label: "Contexts"}
checkSurvived = func() {
t.Helper()
if _, err := os.Stat(marker); err != nil {
t.Fatalf("parent marker was removed: %v", err)
}
if _, err := os.Stat(dir); err != nil {
t.Fatalf("storage dir was removed: %v", err)
}
}
return
}
func TestStorage_RejectsPathTraversal(t *testing.T) {
if _, err := i18n.Init("en"); err != nil {
t.Fatalf("i18n.Init() error = %v", err)
}
storage, checkSurvived := newTraversalFixture(t)
for _, name := range invalidStorageNames {
t.Run(name, func(t *testing.T) {
if err := storage.Delete(name); err == nil {
t.Fatalf("Delete(%q) succeeded, want error", name)
}
if err := storage.Save(name, []byte("pwned")); err == nil {
t.Fatalf("Save(%q) succeeded, want error", name)
}
if _, err := storage.Load(name); err == nil {
t.Fatalf("Load(%q) succeeded, want error", name)
}
if storage.Exists(name) {
t.Fatalf("Exists(%q) is true, want false", name)
}
})
}
checkSurvived()
}
func TestInvalidStorageNameError_DefaultMessage(t *testing.T) {
if _, err := i18n.Init("en"); err != nil {
t.Fatalf("i18n.Init() error = %v", err)
}
err := &InvalidStorageNameError{Name: "bad:name"}
if got, want := err.Error(), `invalid name: "bad:name"`; got != want {
t.Fatalf("Error() = %q, want %q", got, want)
}
}
func TestStorage_Rename(t *testing.T) {
dir := t.TempDir()
storage := &StorageEntity{Dir: dir, Label: "Contexts"}
if err := storage.Save("old", []byte("content")); err != nil {
t.Fatalf("failed to save content: %v", err)
}
if err := storage.Rename("old", "new"); err != nil {
t.Fatalf("failed to rename: %v", err)
}
if storage.Exists("old") {
t.Errorf("expected old name to be gone")
}
loaded, err := storage.Load("new")
if err != nil {
t.Fatalf("failed to load renamed content: %v", err)
}
if string(loaded) != "content" {
t.Errorf("expected %q, got %q", "content", string(loaded))
}
}
func TestStorage_RenameRejectsPathTraversal(t *testing.T) {
if _, err := i18n.Init("en"); err != nil {
t.Fatalf("i18n.Init() error = %v", err)
}
storage, checkSurvived := newTraversalFixture(t)
if err := storage.Save("ok", []byte("content")); err != nil {
t.Fatalf("failed to save content: %v", err)
}
for _, name := range invalidStorageNames {
t.Run(name, func(t *testing.T) {
if err := storage.Rename("ok", name); err == nil {
t.Fatalf("Rename(%q, %q) succeeded, want error", "ok", name)
}
if err := storage.Rename(name, "ok"); err == nil {
t.Fatalf("Rename(%q, %q) succeeded, want error", name, "ok")
}
})
}
checkSurvived()
if !storage.Exists("ok") {
t.Fatalf("legitimate entry was moved or deleted")
}
}
// mustSymlink makes a symlink. If symlinks are not available, for
// example on Windows without the privilege, it skips the test.
func mustSymlink(t *testing.T, target, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Skipf("cannot create symlink: %v", err)
}
}
func TestStorage_RejectsSymlinkEscape(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "store")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
outside := filepath.Join(root, "outside.txt")
if err := os.WriteFile(outside, []byte("keep"), 0o644); err != nil {
t.Fatal(err)
}
mustSymlink(t, outside, filepath.Join(dir, "escape"))
mustSymlink(t, filepath.Join(root, "missing.txt"), filepath.Join(dir, "dangling"))
storage := &StorageEntity{Dir: dir}
for _, name := range []string{"escape", "dangling"} {
if _, err := storage.Load(name); err == nil {
t.Fatalf("Load(%q) through an outside symlink did not fail", name)
}
if err := storage.Save(name, []byte("pwned")); err == nil {
t.Fatalf("Save(%q) through an outside symlink did not fail", name)
}
}
if got, _ := os.ReadFile(outside); string(got) != "keep" {
t.Fatalf("outside file was overwritten: %q", got)
}
if _, err := os.Stat(filepath.Join(root, "missing.txt")); err == nil {
t.Fatal("dangling symlink target was created")
}
}
// Load and Save operate through a symlink that stays in the storage
// directory, and through a storage directory that is a symlink.
func TestStorage_AllowsInternalAndDirSymlinks(t *testing.T) {
root := t.TempDir()
realDir := filepath.Join(root, "real")
if err := os.MkdirAll(realDir, 0o755); err != nil {
t.Fatal(err)
}
storage := &StorageEntity{Dir: realDir}
if err := storage.Save("target", []byte("content")); err != nil {
t.Fatal(err)
}
mustSymlink(t, filepath.Join(realDir, "target"), filepath.Join(realDir, "alias"))
if got, err := storage.Load("alias"); err != nil || string(got) != "content" {
t.Fatalf("Load through an internal symlink: got %q, err %v", got, err)
}
linkDir := filepath.Join(root, "link")
mustSymlink(t, realDir, linkDir)
linked := &StorageEntity{Dir: linkDir}
if got, err := linked.Load("target"); err != nil || string(got) != "content" {
t.Fatalf("Load via a symlinked storage dir: got %q, err %v", got, err)
}
if err := linked.Save("new", []byte("x")); err != nil {
t.Fatalf("Save via a symlinked storage dir: %v", err)
}
}

View file

@ -1,18 +1,47 @@
package restapi
import (
"crypto/sha256"
"crypto/subtle"
"fmt"
"net"
"net/http"
"strings"
"github.com/danielmiessler/fabric/internal/i18n"
"github.com/gin-gonic/gin"
)
const APIKeyHeader = "X-API-Key"
// requireAPIKeyForBind rejects a non-loopback bind address that has no
// API key. An empty or unspecified host binds each interface, and that
// counts as non-loopback.
func requireAPIKeyForBind(address, apiKey string) error {
if apiKey != "" {
return nil
}
host := address
if h, _, err := net.SplitHostPort(address); err == nil {
host = h
}
if host == "localhost" {
return nil
}
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
return nil
}
return fmt.Errorf(i18n.T("server_api_key_required"), address)
}
// APIKeyMiddleware validates API key for protected endpoints.
// Swagger documentation endpoints (/swagger/*) are exempt from authentication
// to allow users to browse and test the API documentation freely.
func APIKeyMiddleware(apiKey string) gin.HandlerFunc {
// Compare digests, not the raw values. ConstantTimeCompare returns
// early when the lengths are different, and that shows the length of
// the configured key.
expectedKey := sha256.Sum256([]byte(apiKey))
return func(c *gin.Context) {
// Skip authentication for Swagger documentation endpoints
// This allows public access to API docs even when authentication is enabled
@ -28,7 +57,8 @@ func APIKeyMiddleware(apiKey string) gin.HandlerFunc {
return
}
if headerApiKey != apiKey {
headerKey := sha256.Sum256([]byte(headerApiKey))
if subtle.ConstantTimeCompare(headerKey[:], expectedKey[:]) != 1 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Wrong API Key"})
return
}

View file

@ -73,11 +73,23 @@ func (h *ChatHandler) HandleChat(c *gin.Context) {
if err := c.BindJSON(&request); err != nil {
log.Printf("Error binding JSON: %v", err)
c.Writer.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
setHSTS(c)
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf(i18n.T("server_invalid_request_format"), err)})
return
}
for _, prompt := range request.Prompts {
if rejectUnsafePatternName(c, prompt.PatternName) {
return
}
if rejectInvalidStorageName(c, prompt.ContextName) {
return
}
if rejectInvalidStorageName(c, prompt.SessionName) {
return
}
}
// Add log to check received language field
log.Printf("Received chat request - Language: '%s', Prompts: %d", request.Language, len(request.Prompts))

View file

@ -8,6 +8,7 @@ import (
"fmt"
"io"
"log"
"log/slog"
"math"
"net/http"
"net/url"
@ -45,6 +46,7 @@ type APIConvert struct {
registry *core.PluginRegistry
r *gin.Engine
addr *string
apiKey string
}
type OllamaRequestBody struct {
@ -188,12 +190,49 @@ func parseOllamaNumCtx(options map[string]any) (int, error) {
return contextLength, nil
}
func ServeOllama(registry *core.PluginRegistry, address string, version string) (err error) {
// fabricChatClient sends the /api/chat self-forward, which can contain
// the configured API key. It does not use a proxy, because the default
// transport obeys HTTP_PROXY and can send the key to the proxy. It does
// not obey redirects, and cannot send the key again to a location that
// the operator did not configure.
var fabricChatClient = newFabricChatClient()
func newFabricChatClient() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
return &http.Client{
Transport: transport,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
// ServeOllama operates the Ollama-compatible API server on address. An
// empty apiKey sets authentication to off. This is permitted only for
// loopback binds.
func ServeOllama(registry *core.PluginRegistry, address string, version string, apiKey string) error {
if err := requireAPIKeyForBind(address, apiKey); err != nil {
return err
}
return newOllamaEngine(registry, address, version, apiKey).Run(address)
}
// newOllamaEngine makes the engine but does not start it, which lets
// tests operate the routes. The address parameter is the /api/chat
// forward target, not the listen address that Run gets. In production
// the two are the same value.
func newOllamaEngine(registry *core.PluginRegistry, address string, version string, apiKey string) *gin.Engine {
r := gin.New()
// Middleware
r.Use(gin.Logger())
r.Use(gin.Recovery())
if apiKey != "" {
r.Use(APIKeyMiddleware(apiKey))
} else {
slog.Warn("Starting Ollama-compatible API server without API key authentication. This may pose security risks.")
}
// Register routes
fabricDb := registry.Db
@ -208,6 +247,7 @@ func ServeOllama(registry *core.PluginRegistry, address string, version string)
registry: registry,
r: r,
addr: &address,
apiKey: apiKey,
}
// Ollama Endpoints
r.GET("/api/tags", typeConversion.ollamaTags)
@ -216,13 +256,7 @@ func ServeOllama(registry *core.PluginRegistry, address string, version string)
})
r.POST("/api/chat", typeConversion.ollamaChat)
// Start server
err = r.Run(address)
if err != nil {
return err
}
return
return r
}
func (f APIConvert) ollamaTags(c *gin.Context) {
@ -267,7 +301,7 @@ func (f APIConvert) ollamaChat(c *gin.Context) {
err = json.Unmarshal(body, &prompt)
if err != nil {
log.Printf(i18n.T("ollama_error_unmarshalling_body"), err)
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.T("ollama_error_endpoint")})
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.T("ollama_invalid_request_body")})
return
}
@ -335,14 +369,14 @@ func (f APIConvert) ollamaChat(c *gin.Context) {
fabricChatReq, err := json.Marshal(chat)
if err != nil {
log.Printf(i18n.T("ollama_error_marshalling_body"), err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.T("ollama_failed_create_request")})
return
}
var req *http.Request
baseURL, err := buildFabricChatURL(*f.addr)
if err != nil {
log.Printf(i18n.T("ollama_error_building_chat_url"), err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.T("ollama_failed_create_request")})
return
}
req, err = http.NewRequest("POST", fmt.Sprintf("%s/chat", baseURL), bytes.NewBuffer(fabricChatReq))
@ -351,13 +385,16 @@ func (f APIConvert) ollamaChat(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.T("ollama_failed_create_request")})
return
}
if f.apiKey != "" {
req.Header.Set(APIKeyHeader, f.apiKey)
}
req = req.WithContext(c.Request.Context())
fabricRes, err := http.DefaultClient.Do(req)
fabricRes, err := fabricChatClient.Do(req)
if err != nil {
log.Printf(i18n.T("ollama_error_getting_chat_body"), err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.T("ollama_upstream_request_failed")})
return
}
defer fabricRes.Body.Close()

View file

@ -2,9 +2,16 @@ package restapi
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/danielmiessler/fabric/internal/core"
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
"github.com/gin-gonic/gin"
)
func TestBuildFabricChatURL(t *testing.T) {
@ -361,3 +368,193 @@ func TestParseOllamaNumCtx(t *testing.T) {
})
}
}
func TestNewOllamaEngine_APIKeyWiring(t *testing.T) {
gin.SetMode(gin.TestMode)
registry := &core.PluginRegistry{Db: fsdb.NewDb(t.TempDir())}
getVersion := func(r *gin.Engine, key string) int {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
if key != "" {
req.Header.Set(APIKeyHeader, key)
}
r.ServeHTTP(w, req)
return w.Code
}
withKey := newOllamaEngine(registry, ":0", "test-version", "secret")
if code := getVersion(withKey, ""); code != http.StatusUnauthorized {
t.Fatalf("no key presented: got %d, want 401", code)
}
if code := getVersion(withKey, "secret"); code != http.StatusOK {
t.Fatalf("valid key presented: got %d, want 200", code)
}
withoutKey := newOllamaEngine(registry, ":0", "test-version", "")
if code := getVersion(withoutKey, ""); code != http.StatusOK {
t.Fatalf("no key configured: got %d, want 200", code)
}
}
func TestOllamaChat_ForwardsAPIKeyToChat(t *testing.T) {
gin.SetMode(gin.TestMode)
// Make the loopback /chat route with the middleware installed, the
// same as newOllamaEngine makes it when --api-key is set.
upstream := gin.New()
upstream.Use(APIKeyMiddleware("secret"))
upstream.POST("/chat", func(c *gin.Context) {
c.Writer.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(c.Writer, "data: {\"type\":\"content\",\"format\":\"markdown\",\"content\":\"hi\"}\n\n")
})
server := httptest.NewServer(upstream)
defer server.Close()
chatRequest := func(key string) int {
r := gin.New()
conv := APIConvert{addr: &server.URL, apiKey: key}
r.POST("/api/chat", conv.ollamaChat)
w := httptest.NewRecorder()
body := `{"model":"test:latest","messages":[{"role":"user","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
return w.Code
}
if code := chatRequest("secret"); code != http.StatusOK {
t.Fatalf("matching key: got %d, want 200", code)
}
if code := chatRequest("wrong"); code != http.StatusUnauthorized {
t.Fatalf("wrong key: got %d, want 401", code)
}
}
// The self-forward client must not use a proxy. A proxy gets the
// configured API key, and the operator did not configure that host.
// The redirect test below covers the no-redirect property.
func TestFabricChatClient_NoProxy(t *testing.T) {
transport, ok := fabricChatClient.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport is %T, want *http.Transport", fabricChatClient.Transport)
}
if transport.Proxy != nil {
t.Fatal("self-forward transport has a proxy configured")
}
}
// An upstream redirect must show as an upstream error. The client must
// not go to the redirect target with the API key.
func TestOllamaChat_DoesNotFollowUpstreamRedirect(t *testing.T) {
gin.SetMode(gin.TestMode)
redirectTargetHit := false
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
redirectTargetHit = true
}))
defer target.Close()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/chat", http.StatusFound)
}))
defer upstream.Close()
r := gin.New()
conv := APIConvert{addr: &upstream.URL, apiKey: "secret"}
r.POST("/api/chat", conv.ollamaChat)
w := httptest.NewRecorder()
body := `{"model":"test:latest","messages":[{"role":"user","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if redirectTargetHit {
t.Fatal("the redirect target was contacted")
}
if w.Code != http.StatusFound {
t.Fatalf("got %d, want the upstream 302 surfaced as an error", w.Code)
}
}
// Malformed client JSON is a client error. The answer is a 400 with a
// stable generic message, not a 500.
func TestOllamaChat_MalformedJSONIs400(t *testing.T) {
gin.SetMode(gin.TestMode)
addr := ":0"
r := gin.New()
conv := APIConvert{addr: &addr}
r.POST("/api/chat", conv.ollamaChat)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader("{not json"))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("got %d, want 400", w.Code)
}
}
// An upstream that is not available is a 500. The body must not contain
// the raw transport error, because that error shows the internal
// upstream URL.
func TestOllamaChat_UpstreamFailureHidesDetails(t *testing.T) {
gin.SetMode(gin.TestMode)
server := httptest.NewServer(http.NotFoundHandler())
url := server.URL
server.Close() // nothing listens on url anymore
r := gin.New()
conv := APIConvert{addr: &url}
r.POST("/api/chat", conv.ollamaChat)
w := httptest.NewRecorder()
body := `{"model":"test:latest","messages":[{"role":"user","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("got %d, want 500", w.Code)
}
got := w.Body.String()
if strings.Contains(got, "dial tcp") || strings.Contains(got, strings.TrimPrefix(url, "http://")) {
t.Fatalf("500 body leaks transport details: %s", got)
}
}
// With no configured key, the forwarded request must not contain the header.
func TestOllamaChat_NoKeyOmitsHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
var gotHeader string
upstream := gin.New()
upstream.POST("/chat", func(c *gin.Context) {
gotHeader = c.GetHeader(APIKeyHeader)
c.Writer.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(c.Writer, "data: {\"type\":\"content\",\"format\":\"markdown\",\"content\":\"hi\"}\n\n")
})
server := httptest.NewServer(upstream)
defer server.Close()
r := gin.New()
conv := APIConvert{addr: &server.URL, apiKey: ""}
r.POST("/api/chat", conv.ollamaChat)
w := httptest.NewRecorder()
body := `{"model":"test:latest","messages":[{"role":"user","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("got %d, want 200", w.Code)
}
if gotHeader != "" {
t.Fatalf("X-API-Key was forwarded with no key configured: %q", gotHeader)
}
}

View file

@ -0,0 +1,330 @@
package restapi
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/danielmiessler/fabric/internal/i18n"
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
"github.com/gin-gonic/gin"
)
// Each storage route that gets a name must reject a traversal name, not
// only DELETE. Most routes share storageError through the fsdb layer.
// The exists route validates in the handler, because its storage
// contract returns only a bool.
func TestStorageHandler_RejectsTraversalOnAllRoutes(t *testing.T) {
if _, err := i18n.Init("en"); err != nil {
t.Fatalf("i18n.Init() error = %v", err)
}
gin.SetMode(gin.TestMode)
root := t.TempDir()
contextsDir := filepath.Join(root, "contexts")
if err := os.MkdirAll(contextsDir, 0o755); err != nil {
t.Fatal(err)
}
marker := filepath.Join(root, "keep.txt")
if err := os.WriteFile(marker, []byte("keep"), 0o644); err != nil {
t.Fatal(err)
}
r := gin.New()
NewContextsHandler(r, &fsdb.ContextsEntity{
StorageEntity: &fsdb.StorageEntity{Label: "Contexts", Dir: contextsDir},
})
for _, tc := range []struct{ method, path string }{
{http.MethodGet, "/contexts/%2e%2e"},
{http.MethodDelete, "/contexts/%2e%2e"},
{http.MethodDelete, "/contexts/.."},
{http.MethodPost, "/contexts/%2e%2e"},
{http.MethodPut, "/contexts/rename/%2e%2e/ok"},
{http.MethodPut, "/contexts/rename/ok/%2e%2e"},
{http.MethodGet, "/contexts/exists/%2e%2e"},
} {
var body io.Reader
if tc.method == http.MethodPost {
body = strings.NewReader("x")
}
w := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, body)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s %s: got %d, want 400", tc.method, tc.path, w.Code)
}
if w.Header().Get("Strict-Transport-Security") == "" {
t.Fatalf("%s %s: validation 400 lacks the HSTS header", tc.method, tc.path)
}
}
if _, err := os.Stat(marker); err != nil {
t.Fatalf("parent marker was deleted: %v", err)
}
if _, err := os.Stat(contextsDir); err != nil {
t.Fatalf("contexts dir was deleted: %v", err)
}
}
// A non-validation failure stays a 500. Its body must not show the
// filesystem path that is in the wrapped *os.PathError.
func TestStorageHandler_GenericErrorHidesPaths(t *testing.T) {
if _, err := i18n.Init("en"); err != nil {
t.Fatalf("i18n.Init() error = %v", err)
}
gin.SetMode(gin.TestMode)
dir := t.TempDir()
r := gin.New()
NewContextsHandler(r, &fsdb.ContextsEntity{
StorageEntity: &fsdb.StorageEntity{Label: "Contexts", Dir: dir},
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/contexts/missing", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("got %d, want 500", w.Code)
}
if strings.Contains(w.Body.String(), dir) {
t.Fatalf("500 body leaks the storage path: %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), "internal error") {
t.Fatalf("500 body is not the generic message: %s", w.Body.String())
}
}
// A pattern backend failure must use the shared storageError mapping.
// That is a JSON envelope with the generic message, never the wrapped
// os.PathError.
func TestPatternsHandler_BackendErrorHidesPaths(t *testing.T) {
if _, err := i18n.Init("en"); err != nil {
t.Fatalf("i18n.Init() error = %v", err)
}
gin.SetMode(gin.TestMode)
dir := t.TempDir()
r := gin.New()
NewPatternsHandler(r, &fsdb.PatternsEntity{
StorageEntity: &fsdb.StorageEntity{Label: "Patterns", Dir: dir, ItemIsDir: true},
SystemPatternFile: "system.md",
})
for _, req := range []*http.Request{
httptest.NewRequest(http.MethodGet, "/patterns/missing", nil),
httptest.NewRequest(http.MethodPost, "/patterns/missing/apply", strings.NewReader(`{"input":"x"}`)),
} {
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("%s %s: got %d, want 500", req.Method, req.URL.Path, w.Code)
}
if strings.Contains(w.Body.String(), dir) {
t.Fatalf("%s %s: 500 body leaks the storage path: %s", req.Method, req.URL.Path, w.Body.String())
}
if !strings.Contains(w.Body.String(), "internal error") {
t.Fatalf("%s %s: 500 body is not the generic envelope: %s", req.Method, req.URL.Path, w.Body.String())
}
}
}
func TestPatternsHandler_RejectsPathTraversalSave(t *testing.T) {
if _, err := i18n.Init("en"); err != nil {
t.Fatalf("i18n.Init() error = %v", err)
}
gin.SetMode(gin.TestMode)
root := t.TempDir()
patternsDir := filepath.Join(root, "patterns")
if err := os.MkdirAll(patternsDir, 0o755); err != nil {
t.Fatal(err)
}
r := gin.New()
NewPatternsHandler(r, &fsdb.PatternsEntity{
StorageEntity: &fsdb.StorageEntity{Label: "Patterns", Dir: patternsDir, ItemIsDir: true},
SystemPatternFile: "system.md",
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/patterns/%2e%2e", strings.NewReader("pwned"))
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("POST /patterns/%%2e%%2e: got %d, want 400", w.Code)
}
if _, err := os.Stat(filepath.Join(root, "system.md")); err == nil {
t.Fatal("wrote system.md in the parent directory")
}
}
func TestChatHandler_RejectsUnsafeNames(t *testing.T) {
if _, err := i18n.Init("en"); err != nil {
t.Fatalf("i18n.Init() error = %v", err)
}
gin.SetMode(gin.TestMode)
// The zero-value handler is an intentional seam. A request that goes
// through pre-validation causes a nil panic in HandleChat. These
// tests cannot pass on a request that got no validation.
r := gin.New()
r.POST("/chat", (&ChatHandler{}).HandleChat)
cases := map[string]string{
// Every prefix class loadPattern treats as a filesystem path
"absolute pattern name": `{"prompts":[{"patternName":"/etc/hosts","userInput":"x"}]}`,
"home pattern name": `{"prompts":[{"patternName":"~/secret.md","userInput":"x"}]}`,
"relative pattern name": `{"prompts":[{"patternName":"./secret.md","userInput":"x"}]}`,
"backslash pattern name": `{"prompts":[{"patternName":"\\secret.md","userInput":"x"}]}`,
// Context and session names get the same pre-validation
"traversal context name": `{"prompts":[{"userInput":"x","contextName":"../keep.txt"}]}`,
"traversal session name": `{"prompts":[{"userInput":"x","sessionName":".."}]}`,
// The rejection loop stops at the first bad name, at each depth
"second prompt path-like": `{"prompts":[{"userInput":"x"},{"patternName":"/etc/hosts","userInput":"y"}]}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/chat", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("got status %d, want 400", w.Code)
}
})
}
}
func TestPatternsHandler_RejectsUnsafeNamesOnReadRoutes(t *testing.T) {
if _, err := i18n.Init("en"); err != nil {
t.Fatalf("i18n.Init() error = %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
NewPatternsHandler(r, &fsdb.PatternsEntity{
StorageEntity: &fsdb.StorageEntity{Label: "Patterns", Dir: t.TempDir(), ItemIsDir: true},
SystemPatternFile: "system.md",
})
for _, req := range []*http.Request{
httptest.NewRequest(http.MethodGet, "/patterns/%2e%2e", nil),
httptest.NewRequest(http.MethodPost, "/patterns/%2e%2e/apply", strings.NewReader(`{"input":"x"}`)),
// Names that fail only ValidateStorageName, not the file-path check
httptest.NewRequest(http.MethodGet, "/patterns/foo:bar", nil),
httptest.NewRequest(http.MethodGet, "/patterns/NUL", nil),
httptest.NewRequest(http.MethodPost, "/patterns/foo:bar/apply", strings.NewReader(`{"input":"x"}`)),
} {
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s %s: got %d, want 400", req.Method, req.URL.Path, w.Code)
}
}
}
// A safe request must go through pre-validation. With the zero-value
// handler seam, a request that goes through causes a nil panic, and
// Recovery changes that into a 500. A 400 shows that validation
// rejected valid names.
func TestChatHandler_AcceptsBenignNames(t *testing.T) {
if _, err := i18n.Init("en"); err != nil {
t.Fatalf("i18n.Init() error = %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(gin.Recovery())
r.POST("/chat", (&ChatHandler{}).HandleChat)
w := httptest.NewRecorder()
body := `{"prompts":[{"userInput":"x","patternName":"summarize","contextName":"myctx","sessionName":"mysession"}]}`
req := httptest.NewRequest(http.MethodPost, "/chat", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code == http.StatusBadRequest {
t.Fatalf("benign request was rejected with 400: %s", w.Body.String())
}
}
// A non-loopback bind without an API key must fail closed before the
// server starts. A loopback bind operates without a key.
func TestRequireAPIKeyForBind(t *testing.T) {
tests := []struct {
address string
apiKey string
wantErr bool
}{
{"127.0.0.1:8080", "", false},
{"localhost:8080", "", false},
{"localhost", "", false},
{"[::1]:8080", "", false},
{":8080", "", true}, // wildcard bind exposes every interface
{"0.0.0.0:8080", "", true},
{"[::]:8080", "", true},
{"192.168.1.50:8080", "", true},
{"example.com:8080", "", true},
{":8080", "secret", false},
{"0.0.0.0:8080", "secret", false},
}
for _, tt := range tests {
t.Run(tt.address, func(t *testing.T) {
err := requireAPIKeyForBind(tt.address, tt.apiKey)
if (err != nil) != tt.wantErr {
t.Fatalf("requireAPIKeyForBind(%q, %q) error = %v, wantErr %v", tt.address, tt.apiKey, err, tt.wantErr)
}
})
}
}
// Serve and ServeOllama must return the fail-closed error and must not
// start an unauthenticated server on a non-loopback bind. The registry
// is nil, and a check that does not occur first causes a panic.
func TestServeFailsClosedOnNonLoopbackBind(t *testing.T) {
if err := Serve(nil, ":0", ""); err == nil {
t.Fatal("Serve on a wildcard bind without a key did not fail")
}
if err := ServeOllama(nil, ":0", "v", ""); err == nil {
t.Fatal("ServeOllama on a wildcard bind without a key did not fail")
}
}
func TestAPIKeyMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(APIKeyMiddleware("secret"))
r.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) })
t.Run("missing key", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("got %d, want 401", w.Code)
}
})
t.Run("wrong key", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
req.Header.Set(APIKeyHeader, "wrong")
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("got %d, want 401", w.Code)
}
})
t.Run("valid key", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
req.Header.Set(APIKeyHeader, "secret")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("got %d, want 200", w.Code)
}
})
}

View file

@ -1,9 +1,11 @@
package restapi
import (
"fmt"
"maps"
"net/http"
"github.com/danielmiessler/fabric/internal/i18n"
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
"github.com/gin-gonic/gin"
)
@ -14,6 +16,22 @@ type PatternsHandler struct {
patterns *fsdb.PatternsEntity
}
// rejectUnsafePatternName answers a 400 when name is a file-path-like
// pattern name or does not obey storage-name validation. An empty name
// passes, because the chat handler guards prompt.PatternName, which is
// optional.
func rejectUnsafePatternName(c *gin.Context, name string) bool {
if name == "" {
return false
}
if fsdb.LooksLikePatternFilePath(name) || fsdb.ValidateStorageName(name) != nil {
setHSTS(c)
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf(i18n.T("pattern_invalid_name"), name)})
return true
}
return false
}
// NewPatternsHandler creates a new PatternsHandler
func NewPatternsHandler(r *gin.Engine, patterns *fsdb.PatternsEntity) (ret *PatternsHandler) {
// Create a storage handler but don't register any routes yet
@ -40,15 +58,19 @@ func NewPatternsHandler(r *gin.Engine, patterns *fsdb.PatternsEntity) (ret *Patt
// @Produce json
// @Param name path string true "Pattern name"
// @Success 200 {object} fsdb.Pattern
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security ApiKeyAuth
// @Router /patterns/{name} [get]
func (h *PatternsHandler) Get(c *gin.Context) {
name := c.Param("name")
if rejectUnsafePatternName(c, name) {
return
}
pattern, err := h.patterns.GetRaw(name)
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
storageError(c, err)
return
}
c.JSON(http.StatusOK, pattern)
@ -75,6 +97,9 @@ type PatternApplyRequest struct {
// @Router /patterns/{name}/apply [post]
func (h *PatternsHandler) ApplyPattern(c *gin.Context) {
name := c.Param("name")
if rejectUnsafePatternName(c, name) {
return
}
var request PatternApplyRequest
if err := c.ShouldBindJSON(&request); err != nil {
@ -93,7 +118,7 @@ func (h *PatternsHandler) ApplyPattern(c *gin.Context) {
pattern, err := h.patterns.GetApplyVariables(name, variables, request.Input)
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
storageError(c, err)
return
}
c.JSON(http.StatusOK, pattern)

View file

@ -7,6 +7,7 @@ import (
"path/filepath"
"github.com/danielmiessler/fabric/internal/core"
"github.com/danielmiessler/fabric/internal/i18n"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
@ -27,6 +28,10 @@ import (
// @in header
// @name X-API-Key
func Serve(registry *core.PluginRegistry, address string, apiKey string) (err error) {
if err = requireAPIKeyForBind(address, apiKey); err != nil {
return err
}
r := gin.New()
// Middleware
@ -36,7 +41,7 @@ func Serve(registry *core.PluginRegistry, address string, apiKey string) (err er
if apiKey != "" {
r.Use(APIKeyMiddleware(apiKey))
} else {
slog.Warn("Starting REST API server without API key authentication. This may pose security risks.")
slog.Warn(i18n.T("server_no_api_key_warning"))
}
// Swagger UI and documentation endpoint with custom YAML handler

View file

@ -1,11 +1,14 @@
package restapi
import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"github.com/danielmiessler/fabric/internal/plugins/db"
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
"github.com/gin-gonic/gin"
)
@ -14,6 +17,42 @@ type StorageHandler[T any] struct {
storage db.Storage[T]
}
// setHSTS sets the Strict-Transport-Security header. Each validation
// 400 sends it, the same as the chat BindJSON 400 path.
func setHSTS(c *gin.Context) {
c.Writer.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
}
// storageError answers err. A name-validation rejection becomes a 400,
// and its body contains only the rejected name. All other errors stay
// 500 errors with a generic body, because fsdb wraps *os.PathError
// values and err.Error() then sends absolute filesystem paths to the
// client. The full error goes to the log.
func storageError(c *gin.Context, err error) {
if _, ok := errors.AsType[*fsdb.InvalidStorageNameError](err); ok {
setHSTS(c)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
slog.Error("storage operation failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
}
// rejectInvalidStorageName answers a 400 when name does not obey
// storage-name validation. An empty name passes, because the fields
// that this guards are optional.
func rejectInvalidStorageName(c *gin.Context, name string) bool {
if name == "" {
return false
}
if err := fsdb.ValidateStorageName(name); err != nil {
setHSTS(c)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return true
}
return false
}
// NewStorageHandler creates a new StorageHandler
func NewStorageHandler[T any](r *gin.Engine, entityType string, storage db.Storage[T]) (ret *StorageHandler[T]) {
ret = &StorageHandler[T]{storage: storage}
@ -31,7 +70,7 @@ func (h *StorageHandler[T]) Get(c *gin.Context) {
name := c.Param("name")
item, err := h.storage.Get(name)
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
storageError(c, err)
return
}
c.JSON(http.StatusOK, item)
@ -41,7 +80,7 @@ func (h *StorageHandler[T]) Get(c *gin.Context) {
func (h *StorageHandler[T]) GetNames(c *gin.Context) {
names, err := h.storage.GetNames()
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
storageError(c, err)
return
}
c.JSON(http.StatusOK, names)
@ -52,15 +91,20 @@ func (h *StorageHandler[T]) Delete(c *gin.Context) {
name := c.Param("name")
err := h.storage.Delete(name)
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
storageError(c, err)
return
}
c.Status(http.StatusOK)
}
// Exists handles the GET /storage/exists/:name route
// Exists handles the GET /storage/exists/:name route. The storage
// Exists contract cannot report an invalid name, and the handler must
// validate the name itself. An invalid name is a 400, not a "false".
func (h *StorageHandler[T]) Exists(c *gin.Context) {
name := c.Param("name")
if rejectInvalidStorageName(c, name) {
return
}
exists := h.storage.Exists(name)
c.JSON(http.StatusOK, exists)
}
@ -71,7 +115,7 @@ func (h *StorageHandler[T]) Rename(c *gin.Context) {
newName := c.Param("newName")
err := h.storage.Rename(oldName, newName)
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
storageError(c, err)
return
}
c.Status(http.StatusOK)
@ -87,14 +131,14 @@ func (h *StorageHandler[T]) Save(c *gin.Context) {
content, err := io.ReadAll(body)
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
storageError(c, err)
return
}
// Save the content to storage
err = h.storage.Save(name, content)
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
storageError(c, err)
return
}
c.Status(http.StatusOK)

View file

@ -39,13 +39,17 @@ docker run --rm -it -v $PWD/.env:/root/.config/fabric/.env fabric -p your-patter
## Running the server
Expose port 8080 to use Fabric's REST API:
Expose port 8080 to use Fabric's REST API. In a container, bind all
interfaces with `--address :8080` so the mapped port can reach the
server, and set an API key, which is mandatory for non-loopback binds:
```bash
docker run --rm -it -p 8080:8080 -v $HOME/.fabric-config:/root/.config/fabric fabric --serve
docker run --rm -it -p 8080:8080 -v $HOME/.fabric-config:/root/.config/fabric \
-e FABRIC_API_KEY=your-secret-key fabric --serve --address :8080
```
The API will be available at `http://localhost:8080`.
The API will be available at `http://localhost:8080`. Requests must send
the key in the `X-API-Key` header.
## Multi-arch builds and GHCR packages