From 4b0eaf25ce17ca6d28c01ac1380d672c83024514 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Thu, 27 Mar 2025 18:52:13 +0000 Subject: [PATCH] add: add env masking extension for .env files Introduce a new extension for CodeMirror that masks sensitive values in .env files. This ensures that sensitive information like API keys or passwords is not displayed in plain text within the editor. The extension dynamically applies masking to values in lines matching the KEY=VALUE format, improving security during development. --- .../editor/codemirror/CodeMirrorEditor.tsx | 6 ++ .../editor/codemirror/EnvMasking.ts | 80 +++++++++++++++++++ app/routes/api.supabase.variables.ts | 2 +- 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 app/components/editor/codemirror/EnvMasking.ts diff --git a/app/components/editor/codemirror/CodeMirrorEditor.tsx b/app/components/editor/codemirror/CodeMirrorEditor.tsx index 8e9f3a3f..e222eabb 100644 --- a/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -25,6 +25,7 @@ import { BinaryContent } from './BinaryContent'; import { getTheme, reconfigureTheme } from './cm-theme'; import { indentKeyBinding } from './indent'; import { getLanguage } from './languages'; +import { createEnvMaskingExtension } from './EnvMasking'; const logger = createScopedLogger('CodeMirrorEditor'); @@ -134,6 +135,9 @@ export const CodeMirrorEditor = memo( const [languageCompartment] = useState(new Compartment()); + // Add a compartment for the env masking extension + const [envMaskingCompartment] = useState(new Compartment()); + const containerRef = useRef(null); const viewRef = useRef(); const themeRef = useRef(); @@ -214,6 +218,7 @@ export const CodeMirrorEditor = memo( if (!doc) { const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [ languageCompartment.of([]), + envMaskingCompartment.of([]), ]); view.setState(state); @@ -236,6 +241,7 @@ export const CodeMirrorEditor = memo( if (!state) { state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [ languageCompartment.of([]), + envMaskingCompartment.of([createEnvMaskingExtension(() => docRef.current?.filePath)]), ]); editorStates.set(doc.filePath, state); diff --git a/app/components/editor/codemirror/EnvMasking.ts b/app/components/editor/codemirror/EnvMasking.ts new file mode 100644 index 00000000..5c9077d4 --- /dev/null +++ b/app/components/editor/codemirror/EnvMasking.ts @@ -0,0 +1,80 @@ +import { EditorView, Decoration, type DecorationSet, ViewPlugin, WidgetType } from '@codemirror/view'; + +// Create a proper WidgetType class for the masked text +class MaskedTextWidget extends WidgetType { + constructor(private readonly _value: string) { + super(); + } + + eq(other: MaskedTextWidget) { + return other._value === this._value; + } + + toDOM() { + const span = document.createElement('span'); + span.textContent = '*'.repeat(this._value.length); + span.className = 'cm-masked-text'; + + return span; + } + + ignoreEvent() { + return false; + } +} + +export function createEnvMaskingExtension(getFilePath: () => string | undefined) { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + } + + update(update: { docChanged: boolean; view: EditorView; viewportChanged: boolean }) { + if (update.docChanged || update.viewportChanged) { + this.decorations = this.buildDecorations(update.view); + } + } + + buildDecorations(view: EditorView) { + const filePath = getFilePath(); + const isEnvFile = filePath?.endsWith('.env') || filePath?.includes('.env.') || filePath?.includes('/.env'); + + if (!isEnvFile) { + return Decoration.none; + } + + const decorations: any[] = []; + const doc = view.state.doc; + + for (let i = 1; i <= doc.lines; i++) { + const line = doc.line(i); + const text = line.text; + + // Match lines with KEY=VALUE format + const match = text.match(/^([^=]+)=(.+)$/); + + if (match && !text.trim().startsWith('#')) { + const [, key, value] = match; + const valueStart = line.from + key.length + 1; + + // Create a decoration that replaces the value with asterisks + decorations.push( + Decoration.replace({ + inclusive: true, + widget: new MaskedTextWidget(value), + }).range(valueStart, line.to), + ); + } + } + + return Decoration.set(decorations); + } + }, + { + decorations: (v) => v.decorations, + }, + ); +} diff --git a/app/routes/api.supabase.variables.ts b/app/routes/api.supabase.variables.ts index 55e796cf..45eccb23 100644 --- a/app/routes/api.supabase.variables.ts +++ b/app/routes/api.supabase.variables.ts @@ -1,4 +1,4 @@ -import { json, type ActionFunctionArgs } from '@remix-run/node'; +import { json, type ActionFunctionArgs } from '@remix-run/cloudflare'; export async function action({ request }: ActionFunctionArgs) { try {