├── .gitignore ├── packages ├── shared │ ├── src │ │ ├── index.ts │ │ └── types.ts │ ├── tsconfig.json │ └── package.json ├── .DS_Store ├── backend │ ├── tsconfig.json │ ├── package.json │ └── src │ │ ├── services │ │ ├── settings.ts │ │ ├── interactshApi.ts │ │ ├── crypto │ │ │ ├── index.ts │ │ │ ├── aes.ts │ │ │ └── rsa.ts │ │ └── interactsh.ts │ │ ├── stores │ │ ├── settings.ts │ │ └── interactsh.ts │ │ └── index.ts └── frontend │ ├── tailwind.config.js │ ├── src │ ├── types.ts │ ├── styles │ │ └── index.css │ ├── utils │ │ └── try-catch.ts │ ├── plugins │ │ └── sdk.ts │ ├── stores │ │ ├── uiStore.ts │ │ ├── editorStore.ts │ │ ├── settingsStore.ts │ │ └── interactionStore.ts │ ├── index.ts │ ├── components │ │ ├── TaggedUrlDialog.vue │ │ ├── MultiUrlDialog.vue │ │ ├── UrlManagerDialog.vue │ │ ├── SettingsDialog.vue │ │ ├── ActionBar.vue │ │ ├── PayloadTable.vue │ │ └── FilterBar.vue │ ├── views │ │ └── App.vue │ └── composables │ │ └── useLogic.ts │ ├── tsconfig.json │ └── package.json ├── pnpm-workspace.yaml ├── img.png ├── eslint.config.js ├── tsconfig.json ├── package.json ├── .github └── workflows │ ├── validate.yml │ └── release.yml ├── LICENSE ├── README.md └── caido.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /packages/shared/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | -------------------------------------------------------------------------------- /img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caido-community/quickssrf/HEAD/img.png -------------------------------------------------------------------------------- /packages/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caido-community/quickssrf/HEAD/packages/.DS_Store -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["./src/**/*.ts"] 4 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { defaultConfig } from "@caido/eslint-config"; 2 | 3 | export default [ 4 | ...defaultConfig({ 5 | stylistic: false, 6 | compat: false, 7 | }), 8 | ]; 9 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["@caido/sdk-backend"], 5 | //"strictPropertyInitialization": false 6 | }, 7 | "include": ["./src/**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | 8 | plugins: [], 9 | corePlugins: { 10 | preflight: false, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/frontend/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Caido } from "@caido/sdk-frontend"; 2 | import type { API, BackendEvents } from "backend"; 3 | import type { Interaction as BaseInteraction } from "shared"; 4 | 5 | export type FrontendSDK = Caido; 6 | 7 | export interface Interaction extends BaseInteraction { 8 | httpPath: string; 9 | } 10 | -------------------------------------------------------------------------------- /packages/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "plugins": [{ "name": "@vue/typescript-plugin" }], 5 | "lib": ["DOM", "ESNext"], 6 | "types": ["@caido/sdk-backend"], 7 | "baseUrl": ".", 8 | "paths": { 9 | "@/*": ["src/*"] 10 | } 11 | }, 12 | "include": ["./src/**/*.ts", "./src/**/*.vue"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/frontend/src/styles/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | @keyframes spin-slow { 6 | from { 7 | transform: rotate(0deg); 8 | } 9 | to { 10 | transform: rotate(360deg); 11 | } 12 | } 13 | 14 | .star-icon { 15 | color: #fbbf24; 16 | animation: spin-slow 3s linear infinite; 17 | } 18 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shared", 3 | "version": "0.0.1", 4 | "description": "Shared types between frontend and backend", 5 | "author": "Caido Labs Inc. ", 6 | "license": "CC0-1.0", 7 | "type": "module", 8 | "types": "src/index.ts", 9 | "scripts": { 10 | "typecheck": "tsc --noEmit" 11 | }, 12 | "dependencies": { 13 | "zod": "3.23.8" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "types": "src/index.ts", 6 | "scripts": { 7 | "typecheck": "tsc --noEmit" 8 | }, 9 | "devDependencies": { 10 | "@caido/quickjs-types": "^0.17.2", 11 | "@caido/sdk-backend": "0.47.1", 12 | "@types/node": "^22.13.11", 13 | "shared": "workspace:*" 14 | }, 15 | "dependencies": { 16 | "shared": "workspace:*" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "lib": ["ESNext"], 6 | 7 | "jsx": "preserve", 8 | "noImplicitAny": true, 9 | "noUncheckedIndexedAccess": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "resolveJsonModule": true, 13 | 14 | "moduleResolution": "bundler", 15 | "esModuleInterop": true, 16 | "sourceMap": true, 17 | "noUnusedLocals": true, 18 | 19 | "useDefineForClassFields": true, 20 | "isolatedModules": true, 21 | 22 | "baseUrl": "." 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/frontend/src/utils/try-catch.ts: -------------------------------------------------------------------------------- 1 | // Types for the result object with discriminated union 2 | type Success = { 3 | data: T; 4 | error: undefined; 5 | }; 6 | 7 | type Failure = { 8 | data: undefined; 9 | error: E; 10 | }; 11 | 12 | type Result = Success | Failure; 13 | 14 | // Main wrapper function 15 | export async function tryCatch( 16 | promise: Promise, 17 | ): Promise> { 18 | try { 19 | const data = await promise; 20 | return { data, error: undefined }; 21 | } catch (error) { 22 | return { data: undefined, error: error as E }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/backend/src/services/settings.ts: -------------------------------------------------------------------------------- 1 | import type { SDK } from "caido:plugin"; 2 | import type { Settings } from "shared"; 3 | 4 | import { SettingsStore } from "../stores/settings"; 5 | 6 | export const getSettings = (sdk: SDK) => { 7 | const store = SettingsStore.get(sdk); 8 | return store.getSettings(); 9 | }; 10 | 11 | export const updateSettings = (sdk: SDK, newSettings: Partial) => { 12 | const store = SettingsStore.get(sdk); 13 | return store.updateSettings(sdk, newSettings); 14 | }; 15 | 16 | export const resetSettings = (sdk: SDK) => { 17 | const store = SettingsStore.get(sdk); 18 | return store.resetSettings(); 19 | }; 20 | -------------------------------------------------------------------------------- /packages/frontend/src/plugins/sdk.ts: -------------------------------------------------------------------------------- 1 | import { inject, type InjectionKey, type Plugin } from "vue"; 2 | 3 | import type { FrontendSDK } from "@/types"; 4 | 5 | const KEY: InjectionKey = Symbol("FrontendSDK"); 6 | 7 | /* 8 | * This is the plugin that will provide the FrontendSDK to VueJS 9 | * To access the frontend SDK from within a component, use the `useSDK` function. 10 | */ 11 | export const SDKPlugin: Plugin = (app, sdk: FrontendSDK) => { 12 | app.provide(KEY, sdk); 13 | }; 14 | 15 | // This is the function that will be used to access the FrontendSDK from within a component. 16 | export const useSDK = () => { 17 | return inject(KEY) as FrontendSDK; 18 | }; 19 | -------------------------------------------------------------------------------- /packages/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "typecheck": "vue-tsc --noEmit" 7 | }, 8 | "dependencies": { 9 | "@caido/primevue": "0.1.8", 10 | "@caido/sdk-frontend": "0.47.1", 11 | "@fortawesome/fontawesome-free": "6.7.2", 12 | "@vue/typescript-plugin": "^2.2.8", 13 | "@vueuse/core": "13.0.0", 14 | "pinia": "3.0.1", 15 | "primevue": "4.3.2", 16 | "shared": "workspace:*", 17 | "uuid": "^11.1.0", 18 | "vue": "3.5.13" 19 | }, 20 | "devDependencies": { 21 | "@caido/sdk-backend": "0.47.1", 22 | "@codemirror/view": "6.36.4", 23 | "backend": "workspace:*", 24 | "vue-tsc": "2.2.8" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quickssrf", 3 | "version": "0.3.0", 4 | "description": "QuickSSRF is a tool for detecting out-of-band vulnerabilities via server interactions.", 5 | "author": "w2xim3 , bebiks ", 6 | "license": "MIT", 7 | "type": "module", 8 | "scripts": { 9 | "typecheck": "pnpm -r typecheck", 10 | "build": "caido-dev build", 11 | "watch": "caido-dev watch", 12 | "lint": "eslint --fix packages/*/src" 13 | }, 14 | "devDependencies": { 15 | "@caido-community/dev": "0.1.5", 16 | "@caido/eslint-config": "0.2.0", 17 | "@caido/tailwindcss": "0.0.1", 18 | "@vitejs/plugin-vue": "5.2.3", 19 | "eslint": "9.23.0", 20 | "postcss-prefixwrap": "1.54.0", 21 | "tailwindcss": "3.4.13", 22 | "tailwindcss-primeui": "0.5.1", 23 | "typescript": "5.8.2", 24 | "vite": "6.3.6" 25 | }, 26 | "dependencies": { 27 | "pinia": "3.0.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | pull_request: 8 | 9 | env: 10 | NODE_VERSION: 20 11 | PNPM_VERSION: 9 12 | 13 | jobs: 14 | validate: 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 10 17 | steps: 18 | - name: Checkout project 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ env.NODE_VERSION }} 25 | 26 | - name: Setup pnpm 27 | uses: pnpm/action-setup@v4 28 | with: 29 | version: ${{ env.PNPM_VERSION }} 30 | run_install: false 31 | 32 | - name: Install dependencies 33 | run: pnpm install 34 | 35 | - name: Typecheck 36 | run: pnpm typecheck 37 | 38 | - name: Lint 39 | run: pnpm lint 40 | 41 | - name: Build 42 | run: pnpm build 43 | -------------------------------------------------------------------------------- /packages/shared/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Settings { 2 | serverURL: string; 3 | token: string; 4 | pollingInterval: number; 5 | correlationIdLength: number; 6 | correlationIdNonceLength: number; 7 | } 8 | 9 | export interface Interaction { 10 | protocol: string; 11 | uniqueId: string; 12 | fullId: string; 13 | qType: string; 14 | rawRequest: string; 15 | rawResponse: string; 16 | remoteAddress: string; 17 | timestamp: string; 18 | httpPath?: string; 19 | tag?: string; 20 | serverUrl?: string; 21 | } 22 | 23 | export interface InteractshStartOptions { 24 | serverURL: string; 25 | token: string; 26 | pollingInterval?: number; 27 | correlationIdLength?: number; 28 | correlationIdNonceLength?: number; 29 | } 30 | 31 | export interface GenerateUrlResult { 32 | url: string; 33 | uniqueId: string; 34 | } 35 | 36 | export interface ActiveUrl { 37 | url: string; 38 | uniqueId: string; 39 | createdAt: string; 40 | isActive: boolean; 41 | serverUrl: string; 42 | tag?: string; 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Maxime Paillé 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /packages/frontend/src/stores/uiStore.ts: -------------------------------------------------------------------------------- 1 | import { useClipboard } from "@vueuse/core"; 2 | import { defineStore } from "pinia"; 3 | import { ref } from "vue"; 4 | 5 | import { useSDK } from "@/plugins/sdk"; 6 | import type { Interaction } from "@/types"; 7 | 8 | export const useUIStore = defineStore("ui", () => { 9 | const sdk = useSDK(); 10 | const { copy } = useClipboard(); 11 | 12 | const isGeneratingUrl = ref(false); 13 | const isPolling = ref(false); 14 | 15 | const generatedUrl = ref(""); 16 | const btnCount = ref(0); 17 | const selectedRow = ref(undefined); 18 | 19 | function copyToClipboard(value: string, field: string) { 20 | copy(value); 21 | sdk.window.showToast("Copied to clipboard", { variant: "success" }); 22 | 23 | return true; 24 | } 25 | 26 | function setGeneratedUrl(url: string) { 27 | generatedUrl.value = url; 28 | } 29 | 30 | function clearGeneratedUrl() { 31 | generatedUrl.value = ""; 32 | } 33 | 34 | function clearUI() { 35 | generatedUrl.value = ""; 36 | btnCount.value = 0; 37 | selectedRow.value = undefined; 38 | } 39 | 40 | function setGeneratingUrl(state: boolean) { 41 | isGeneratingUrl.value = state; 42 | } 43 | 44 | function setPolling(state: boolean) { 45 | isPolling.value = state; 46 | } 47 | 48 | function setBtnCount(count: number) { 49 | btnCount.value = count; 50 | } 51 | 52 | function setSelectedRow(row: Interaction | undefined) { 53 | selectedRow.value = row; 54 | } 55 | 56 | return { 57 | isGeneratingUrl, 58 | isPolling, 59 | generatedUrl, 60 | btnCount, 61 | selectedRow, 62 | 63 | copyToClipboard, 64 | setGeneratedUrl, 65 | clearGeneratedUrl, 66 | setGeneratingUrl, 67 | setPolling, 68 | setSelectedRow, 69 | setBtnCount, 70 | clearUI, 71 | }; 72 | }); 73 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | NODE_VERSION: 20 8 | PNPM_VERSION: 9 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | 17 | steps: 18 | - name: Checkout project 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ env.NODE_VERSION }} 25 | 26 | - name: Setup pnpm 27 | uses: pnpm/action-setup@v4 28 | with: 29 | version: ${{ env.PNPM_VERSION }} 30 | run_install: true 31 | 32 | - name: Build package 33 | run: pnpm build 34 | 35 | - name: Sign package 36 | working-directory: dist 37 | run: | 38 | if [[ -z "${{ secrets.PRIVATE_KEY }}" ]]; then 39 | echo "Set an ed25519 key as PRIVATE_KEY in GitHub Action secret to sign." 40 | else 41 | echo "${{ secrets.PRIVATE_KEY }}" > private_key.pem 42 | openssl pkeyutl -sign -inkey private_key.pem -out plugin_package.zip.sig -rawin -in plugin_package.zip 43 | rm private_key.pem 44 | fi 45 | 46 | - name: Check version 47 | id: meta 48 | working-directory: dist 49 | run: | 50 | VERSION=$(unzip -p plugin_package.zip manifest.json | jq -r .version) 51 | echo "version=${VERSION}" >> $GITHUB_OUTPUT 52 | 53 | - name: Create release 54 | uses: ncipollo/release-action@v1 55 | with: 56 | tag: ${{ steps.meta.outputs.version }} 57 | commit: ${{ github.sha }} 58 | body: 'Release ${{ steps.meta.outputs.version }}' 59 | artifacts: 'dist/plugin_package.zip,dist/plugin_package.zip.sig' 60 | immutableCreate: true 61 | -------------------------------------------------------------------------------- /packages/frontend/src/stores/editorStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { ref } from "vue"; 3 | 4 | import type { Interaction } from "@/types"; 5 | 6 | export const useEditorStore = defineStore("editors", () => { 7 | const responseEditorRef = ref(); 8 | const requestEditorRef = ref(); 9 | 10 | const event = new CustomEvent("refreshEditors"); 11 | const eventBus = new EventTarget(); 12 | 13 | function updateEditorContent(interaction: Interaction) { 14 | if (!responseEditorRef.value || !requestEditorRef.value) return; 15 | 16 | responseEditorRef.value.getEditorView().dispatch({ 17 | changes: { 18 | from: 0, 19 | to: responseEditorRef.value.getEditorView().state.doc.length, 20 | insert: interaction.rawResponse, 21 | }, 22 | }); 23 | 24 | requestEditorRef.value.getEditorView().dispatch({ 25 | changes: { 26 | from: 0, 27 | to: requestEditorRef.value.getEditorView().state.doc.length, 28 | insert: interaction.rawRequest, 29 | }, 30 | }); 31 | } 32 | 33 | function clearEditors() { 34 | if (!responseEditorRef.value || !requestEditorRef.value) return; 35 | 36 | responseEditorRef.value.getEditorView().dispatch({ 37 | changes: { 38 | from: 0, 39 | to: responseEditorRef.value.getEditorView().state.doc.length, 40 | insert: "", 41 | }, 42 | }); 43 | 44 | requestEditorRef.value.getEditorView().dispatch({ 45 | changes: { 46 | from: 0, 47 | to: requestEditorRef.value.getEditorView().state.doc.length, 48 | insert: "", 49 | }, 50 | }); 51 | } 52 | 53 | function setRequestEditorRef(ref: HTMLElement) { 54 | requestEditorRef.value = ref; 55 | } 56 | 57 | function setResponseEditorRef(ref: HTMLElement) { 58 | responseEditorRef.value = ref; 59 | } 60 | 61 | function refreshEditors() { 62 | eventBus.dispatchEvent(event); 63 | } 64 | 65 | return { 66 | eventBus, 67 | responseEditorRef, 68 | requestEditorRef, 69 | 70 | updateEditorContent, 71 | clearEditors, 72 | setRequestEditorRef, 73 | setResponseEditorRef, 74 | refreshEditors, 75 | }; 76 | }); 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QuickSSRF - CAIDO Plugin 2 | 3 | ## English Version 4 | 5 | ### QuickSSRF - CAIDO Plugin 6 | 7 | QuickSSRF is a plugin designed to integrate with [CAIDO](https://caido.io/) and leverage the [Interactsh](https://projectdiscovery.io/) service from ProjectDiscovery. This tool is tailored for monitoring and capturing interactions with external services, assisting security professionals in detecting Server-Side Request Forgery (SSRF) vulnerabilities. 8 | 9 | --- 10 | 11 | ### Features 12 | - **Real-time Interaction Monitoring**: Tracks and logs external interactions initiated by the target system. 13 | - **Integration with Interactsh**: Uses ProjectDiscovery's Interactsh API for effortless detection of external callbacks. 14 | - **Enhanced Vulnerability Identification**: Facilitates precise identification of SSRF vulnerabilities. 15 | - **Seamless Integration**: Works directly within CAIDO for streamlined workflows. 16 | 17 | --- 18 | 19 | ### Installation 20 | 1. Clone the repository: 21 | ```bash 22 | git clone https://github.com/your-repo/QuickSSRF.git 23 | cd QuickSSRF 24 | pnpm i 25 | pnpm build 26 | 27 | 28 | # QuickSSRF - Plugin CAIDO 29 | 30 | ## Version Française 31 | 32 | ### QuickSSRF - Plugin CAIDO 33 | 34 | QuickSSRF est un plugin conçu pour s'intégrer avec [CAIDO](https://caido.io/) et utiliser le service [Interactsh](https://projectdiscovery.io/) de ProjectDiscovery. Cet outil est destiné à surveiller et capturer les interactions avec des services externes, aidant les professionnels de la cybersécurité à détecter les vulnérabilités de type SSRF (Server-Side Request Forgery). 35 | 36 | --- 37 | 38 | ### Fonctionnalités 39 | - **Surveillance en temps réel** : Suivi et enregistrement des interactions externes initiées par le système cible. 40 | - **Intégration avec Interactsh** : Utilise l'API d'Interactsh pour détecter facilement les callbacks externes. 41 | - **Identification améliorée des vulnérabilités** : Aide à identifier précisément les vulnérabilités SSRF. 42 | - **Intégration transparente** : Fonctionne directement avec CAIDO pour des flux de travail optimisés. 43 | 44 | --- 45 | ![img.png](https://raw.githubusercontent.com/caido-community/quickssrf/main/img.png) -------------------------------------------------------------------------------- /packages/frontend/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Classic } from "@caido/primevue"; 2 | import { createPinia } from "pinia"; 3 | import PrimeVue from "primevue/config"; 4 | import { createApp } from "vue"; 5 | 6 | import { SDKPlugin } from "./plugins/sdk"; 7 | import "./styles/index.css"; 8 | import type { FrontendSDK } from "./types"; 9 | import App from "./views/App.vue"; 10 | 11 | import { useEditorStore } from "@/stores/editorStore"; 12 | import { useUIStore } from "@/stores/uiStore"; 13 | 14 | const eventBus = new EventTarget(); 15 | export default eventBus; 16 | 17 | // This is the entry point for the frontend plugin 18 | export let sidebarItem: ReturnType; 19 | export const init = (sdk: FrontendSDK) => { 20 | const app = createApp(App); 21 | const pinia = createPinia(); 22 | app.use(pinia); 23 | 24 | // Load the PrimeVue component library 25 | app.use(PrimeVue, { 26 | unstyled: true, 27 | pt: Classic, 28 | }); 29 | 30 | // Provide the FrontendSDK 31 | app.use(SDKPlugin, sdk); 32 | 33 | // Create the root element for the app 34 | const root = document.createElement("div"); 35 | Object.assign(root.style, { 36 | height: "100%", 37 | width: "100%", 38 | }); 39 | 40 | /* 41 | * Set the ID of the root element 42 | * We use the manifest ID to ensure that the ID is unique per-plugin 43 | * This is necessary to prevent styling conflicts between plugins 44 | * The value here should be the same as the prefixWrap plugin in postcss.config.js 45 | */ 46 | root.id = "plugin--quickssrf"; 47 | 48 | // Mount the app to the root element 49 | app.mount(root); 50 | 51 | /* 52 | * Add the page to the navigation 53 | * Make sure to use a unique name for the page 54 | */ 55 | sdk.navigation.addPage("/quickssrf", { 56 | body: root, 57 | onEnter: () => { 58 | const uiStore = useUIStore(); 59 | const editorStore = useEditorStore(); 60 | 61 | uiStore.btnCount = 0; 62 | sidebarItem.setCount(uiStore.btnCount); 63 | editorStore.refreshEditors(); 64 | }, 65 | }); 66 | 67 | sidebarItem = sdk.sidebar.registerItem("QuickSSRF", "/quickssrf", { 68 | icon: "fas fa-globe", 69 | }); 70 | }; 71 | -------------------------------------------------------------------------------- /caido.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | import tailwindCaido from "@caido/tailwindcss"; 4 | import { defineConfig } from "@caido-community/dev"; 5 | import vue from "@vitejs/plugin-vue"; 6 | import prefixwrap from "postcss-prefixwrap"; 7 | import tailwindcss from "tailwindcss"; 8 | import tailwindPrimeui from "tailwindcss-primeui"; 9 | 10 | const id = "quickssrf"; 11 | export default defineConfig({ 12 | id, 13 | name: "QuickSSRF", 14 | version: "0.3.0", 15 | description: "Real-time Interaction Monitoring with Interactsh", 16 | author: { 17 | name: "w2xim3", 18 | email: "dev@caido.io", 19 | }, 20 | plugins: [ 21 | { 22 | id: "quickssrf-frontend", 23 | root: "packages/frontend", 24 | kind: "frontend", 25 | backend: { 26 | id: "quickssrf-backend", 27 | }, 28 | vite: { 29 | // @ts-expect-error 30 | plugins: [vue()], 31 | build: { 32 | rollupOptions: { 33 | external: ["@caido/frontend-sdk"], 34 | }, 35 | }, 36 | resolve: { 37 | alias: [ 38 | { 39 | find: "@", 40 | replacement: path.resolve(__dirname, "packages/frontend/src"), 41 | }, 42 | ], 43 | }, 44 | css: { 45 | postcss: { 46 | plugins: [ 47 | // @ts-expect-error 48 | tailwindcss({ 49 | content: [ 50 | "./packages/frontend/src/**/*.{vue,ts}", 51 | "./node_modules/@caido/primevue/dist/primevue.mjs", 52 | ], 53 | // Check the [data-mode="dark"] attribute on the element to determine the mode 54 | // This attribute is set in the Caido core application 55 | darkMode: ["selector", '[data-mode="dark"]'], 56 | plugins: [ 57 | // This plugin injects the necessary Tailwind classes for PrimeVue components 58 | tailwindPrimeui, 59 | 60 | // This plugin injects the necessary Tailwind classes for the Caido theme 61 | tailwindCaido, 62 | ], 63 | }), 64 | 65 | // This plugin wraps the root element in a unique ID 66 | // This is necessary to prevent styling conflicts between plugins 67 | // @ts-expect-error 68 | prefixwrap(`#plugin--${id}`), 69 | ], 70 | }, 71 | }, 72 | }, 73 | }, 74 | { 75 | id: "quickssrf-backend", 76 | root: "packages/backend", 77 | kind: "backend", 78 | }, 79 | ], 80 | }); 81 | -------------------------------------------------------------------------------- /packages/frontend/src/components/TaggedUrlDialog.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 109 | -------------------------------------------------------------------------------- /packages/frontend/src/components/MultiUrlDialog.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 121 | -------------------------------------------------------------------------------- /packages/backend/src/stores/settings.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | 5 | import type { SDK } from "caido:plugin"; 6 | import { type Settings } from "shared"; 7 | 8 | const defaultSettings = { 9 | serverURL: "https://oast.site", 10 | token: crypto.randomUUID(), 11 | pollingInterval: 30_000, 12 | correlationIdLength: 20, 13 | correlationIdNonceLength: 13, 14 | }; 15 | 16 | export class SettingsStore { 17 | private static _instance?: SettingsStore; 18 | private settings: Settings; 19 | private readonly configPath: string; 20 | private sdk: SDK; 21 | 22 | private constructor(sdk: SDK) { 23 | this.sdk = sdk; 24 | this.configPath = path.join(this.sdk.meta.path(), "config.json"); 25 | this.settings = this.loadSettings(); 26 | } 27 | 28 | static get(sdk: SDK): SettingsStore { 29 | if (!SettingsStore._instance) { 30 | SettingsStore._instance = new SettingsStore(sdk); 31 | } 32 | return SettingsStore._instance; 33 | } 34 | 35 | private validateSettings(settings: Settings): Settings { 36 | // Validation based on interactsh-client behavior: 37 | // - cidl/cidn must be >= 1 (negative values cause panic) 38 | // - No upper limit in code, but DNS labels are max 63 chars 39 | // - Combined length should stay reasonable for DNS compatibility 40 | return { 41 | ...settings, 42 | pollingInterval: Math.max(1000, settings.pollingInterval ?? 30_000), 43 | correlationIdLength: Math.min( 44 | 63, 45 | Math.max(1, settings.correlationIdLength ?? 20), 46 | ), 47 | correlationIdNonceLength: Math.min( 48 | 63, 49 | Math.max(1, settings.correlationIdNonceLength ?? 13), 50 | ), 51 | }; 52 | } 53 | 54 | private loadSettings(): Settings { 55 | try { 56 | const data = fs.readFileSync(this.configPath, { encoding: "utf-8" }); 57 | const parsed = JSON.parse(data); 58 | // Validate and merge with defaults for any missing fields 59 | return this.validateSettings({ ...defaultSettings, ...parsed }); 60 | } catch (error) { 61 | this.settings = { 62 | ...defaultSettings, 63 | }; 64 | 65 | this.writeDefaultSettings(); 66 | return this.settings; 67 | } 68 | } 69 | 70 | private writeDefaultSettings() { 71 | fs.writeFileSync(this.configPath, JSON.stringify(this.settings, null, 2)); 72 | } 73 | 74 | private saveSettings() { 75 | try { 76 | fs.writeFileSync(this.configPath, JSON.stringify(this.settings, null, 2)); 77 | } catch (error) { 78 | this.sdk.console.error(`Failed to save settings: ${error}`); 79 | } 80 | } 81 | 82 | getSettings(): Settings { 83 | return { ...this.settings }; 84 | } 85 | 86 | updateSettings(sdk: SDK, newSettings: Partial): Settings { 87 | // Validate settings before applying 88 | const validated = { ...newSettings }; 89 | 90 | if (validated.pollingInterval !== undefined) { 91 | validated.pollingInterval = Math.max(1000, validated.pollingInterval); 92 | } 93 | 94 | if (validated.correlationIdLength !== undefined) { 95 | validated.correlationIdLength = Math.min( 96 | 63, 97 | Math.max(1, validated.correlationIdLength), 98 | ); 99 | } 100 | 101 | if (validated.correlationIdNonceLength !== undefined) { 102 | validated.correlationIdNonceLength = Math.min( 103 | 63, 104 | Math.max(1, validated.correlationIdNonceLength), 105 | ); 106 | } 107 | 108 | this.settings = { 109 | ...this.settings, 110 | ...validated, 111 | }; 112 | this.saveSettings(); 113 | return this.getSettings(); 114 | } 115 | 116 | resetSettings(): Settings { 117 | this.settings = { 118 | ...defaultSettings, 119 | }; 120 | this.saveSettings(); 121 | return this.getSettings(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /packages/backend/src/services/interactshApi.ts: -------------------------------------------------------------------------------- 1 | import type { SDK } from "caido:plugin"; 2 | import type { 3 | ActiveUrl, 4 | GenerateUrlResult, 5 | Interaction, 6 | InteractshStartOptions, 7 | } from "shared"; 8 | 9 | import { InteractshStore } from "../stores/interactsh"; 10 | 11 | export const startInteractsh = ( 12 | sdk: SDK, 13 | options: InteractshStartOptions, 14 | ): boolean => { 15 | const store = InteractshStore.get(sdk); 16 | return store.start(options); 17 | }; 18 | 19 | export const stopInteractsh = async (sdk: SDK): Promise => { 20 | const store = InteractshStore.get(sdk); 21 | return store.stop(); 22 | }; 23 | 24 | export const generateInteractshUrl = async ( 25 | sdk: SDK, 26 | serverUrl: string, 27 | tag?: string, 28 | ): Promise => { 29 | const store = InteractshStore.get(sdk); 30 | return store.generateUrl(serverUrl, tag); 31 | }; 32 | 33 | export const getInteractions = (sdk: SDK): Interaction[] => { 34 | const store = InteractshStore.get(sdk); 35 | return store.getInteractions(); 36 | }; 37 | 38 | export const getNewInteractions = ( 39 | sdk: SDK, 40 | lastIndex: number, 41 | ): Interaction[] => { 42 | const store = InteractshStore.get(sdk); 43 | return store.getNewInteractions(lastIndex); 44 | }; 45 | 46 | export const pollInteractsh = async ( 47 | sdk: SDK, 48 | notifyOthers = false, 49 | ): Promise => { 50 | const store = InteractshStore.get(sdk); 51 | return store.poll(notifyOthers); 52 | }; 53 | 54 | export const clearInteractions = (sdk: SDK): void => { 55 | const store = InteractshStore.get(sdk); 56 | store.clearInteractions(); 57 | }; 58 | 59 | export const deleteInteraction = (sdk: SDK, uniqueId: string): boolean => { 60 | const store = InteractshStore.get(sdk); 61 | return store.deleteInteraction(uniqueId); 62 | }; 63 | 64 | export const deleteInteractions = (sdk: SDK, uniqueIds: string[]): number => { 65 | const store = InteractshStore.get(sdk); 66 | return store.deleteInteractions(uniqueIds); 67 | }; 68 | 69 | export const getInteractshStatus = ( 70 | sdk: SDK, 71 | ): { isStarted: boolean; interactionCount: number } => { 72 | const store = InteractshStore.get(sdk); 73 | return store.getStatus(); 74 | }; 75 | 76 | // URL Management APIs 77 | export const getActiveUrls = (sdk: SDK): ActiveUrl[] => { 78 | const store = InteractshStore.get(sdk); 79 | return store.getActiveUrls(); 80 | }; 81 | 82 | export const setUrlActive = ( 83 | sdk: SDK, 84 | uniqueId: string, 85 | isActive: boolean, 86 | ): boolean => { 87 | const store = InteractshStore.get(sdk); 88 | return store.setUrlActive(uniqueId, isActive); 89 | }; 90 | 91 | export const removeUrl = (sdk: SDK, uniqueId: string): boolean => { 92 | const store = InteractshStore.get(sdk); 93 | return store.removeUrl(uniqueId); 94 | }; 95 | 96 | export const clearUrls = (sdk: SDK): void => { 97 | const store = InteractshStore.get(sdk); 98 | store.clearUrls(); 99 | }; 100 | 101 | export const clearAllData = (sdk: SDK): void => { 102 | const store = InteractshStore.get(sdk); 103 | store.clearAllData(); 104 | }; 105 | 106 | export const initializeClients = async ( 107 | sdk: SDK, 108 | serverUrls: string[], 109 | ): Promise => { 110 | const store = InteractshStore.get(sdk); 111 | return store.initializeClients(serverUrls); 112 | }; 113 | 114 | export const getClientCount = (sdk: SDK): number => { 115 | const store = InteractshStore.get(sdk); 116 | return store.getClientCount(); 117 | }; 118 | 119 | export const setFilter = (sdk: SDK, filter: string): void => { 120 | const store = InteractshStore.get(sdk); 121 | store.setFilter(filter); 122 | }; 123 | 124 | export const getFilter = (sdk: SDK): string => { 125 | const store = InteractshStore.get(sdk); 126 | return store.getFilter(); 127 | }; 128 | 129 | export const setFilterEnabled = (sdk: SDK, enabled: boolean): void => { 130 | const store = InteractshStore.get(sdk); 131 | store.setFilterEnabled(enabled); 132 | }; 133 | 134 | export const getFilterEnabled = (sdk: SDK): boolean => { 135 | const store = InteractshStore.get(sdk); 136 | return store.getFilterEnabled(); 137 | }; 138 | 139 | export const setInteractionTag = ( 140 | sdk: SDK, 141 | uniqueId: string, 142 | tag: string | undefined, 143 | ): boolean => { 144 | const store = InteractshStore.get(sdk); 145 | return store.setInteractionTag(uniqueId, tag); 146 | }; 147 | -------------------------------------------------------------------------------- /packages/backend/src/services/crypto/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Native crypto implementation for Caido backend 3 | * No external dependencies - pure JavaScript implementations 4 | */ 5 | 6 | import { Buffer } from "buffer"; 7 | import { randomBytes } from "crypto"; 8 | 9 | import { aesCfbDecrypt } from "./aes"; 10 | import { 11 | base64ToUint8Array, 12 | exportPublicKeyPEM, 13 | generateRSAKeyPair, 14 | type RSAKeyPair, 15 | rsaOaepDecrypt, 16 | type RSAPrivateKey, 17 | } from "./rsa"; 18 | 19 | /** 20 | * Convert Uint8Array to UTF-8 string 21 | */ 22 | function uint8ArrayToString(bytes: Uint8Array): string { 23 | let result = ""; 24 | for (let i = 0; i < bytes.length; i++) { 25 | result += String.fromCharCode(bytes[i]!); 26 | } 27 | // Handle UTF-8 multi-byte characters 28 | try { 29 | return decodeURIComponent(escape(result)); 30 | } catch { 31 | return result; 32 | } 33 | } 34 | 35 | // Store for RSA key pair 36 | let keyPair: RSAKeyPair | undefined; 37 | let keysInitialized = false; 38 | 39 | /** 40 | * Initialize RSA-2048 key pair with OAEP padding and SHA-256 41 | */ 42 | export function initializeRSAKeys(): void { 43 | if (keysInitialized) { 44 | return; 45 | } 46 | 47 | keyPair = generateRSAKeyPair(); 48 | keysInitialized = true; 49 | } 50 | 51 | /** 52 | * Check if keys are initialized 53 | */ 54 | export function areKeysInitialized(): boolean { 55 | return keysInitialized; 56 | } 57 | 58 | /** 59 | * Get the private key 60 | */ 61 | export function getPrivateKey(): RSAPrivateKey | undefined { 62 | return keyPair?.privateKey; 63 | } 64 | 65 | /** 66 | * Encode the public key in the format expected by Interactsh 67 | * Returns base64 encoded PEM format 68 | */ 69 | export function encodePublicKey(): string { 70 | if (!keyPair) { 71 | throw new Error("Keys not initialized. Call initializeRSAKeys() first."); 72 | } 73 | 74 | const pemKey = exportPublicKeyPEM(keyPair.publicKey); 75 | return Buffer.from(pemKey).toString("base64"); 76 | } 77 | 78 | /** 79 | * Decrypt data using RSA-OAEP with SHA-256 80 | */ 81 | export function decryptRSA(encodedKey: string): Uint8Array { 82 | if (!keyPair) { 83 | throw new Error("Keys not initialized. Call initializeRSAKeys() first."); 84 | } 85 | 86 | const encryptedData = base64ToUint8Array(encodedKey); 87 | return rsaOaepDecrypt(encryptedData, keyPair.privateKey); 88 | } 89 | 90 | /** 91 | * Decrypt a message from Interactsh 92 | * The key is RSA-encrypted, the message is AES-CFB encrypted 93 | * Format: IV (16 bytes) + ciphertext 94 | */ 95 | export function decryptMessage(key: string, secureMessage: string): string { 96 | if (!keyPair) { 97 | throw new Error("Keys not initialized. Call initializeRSAKeys() first."); 98 | } 99 | 100 | // Step 1: Decrypt the AES key using RSA-OAEP 101 | const encryptedKeyBytes = base64ToUint8Array(key); 102 | const decryptedKey = rsaOaepDecrypt(encryptedKeyBytes, keyPair.privateKey); 103 | 104 | // Step 2: Decode the secure message from base64 105 | const secureMessageBuffer = base64ToUint8Array(secureMessage); 106 | 107 | // Step 3: Extract IV (first 16 bytes) and ciphertext (rest) 108 | const iv = secureMessageBuffer.slice(0, 16); 109 | const ciphertext = secureMessageBuffer.slice(16); 110 | 111 | // Step 4: Decrypt using AES-256-CFB 112 | // Ensure key is 32 bytes for AES-256 113 | let aesKey: Uint8Array; 114 | if (decryptedKey.length === 32) { 115 | aesKey = decryptedKey; 116 | } else if (decryptedKey.length < 32) { 117 | // Pad with zeros if key is shorter 118 | aesKey = new Uint8Array(32); 119 | aesKey.set(decryptedKey); 120 | } else { 121 | // Truncate if key is longer 122 | aesKey = decryptedKey.slice(0, 32); 123 | } 124 | 125 | const decrypted = aesCfbDecrypt(aesKey, iv, ciphertext); 126 | 127 | // Convert to UTF-8 string 128 | return uint8ArrayToString(decrypted); 129 | } 130 | 131 | /** 132 | * Generate a random string of specified length 133 | * Used for correlation ID and secret key generation 134 | */ 135 | export function generateRandomString( 136 | length: number, 137 | lettersOnly: boolean = false, 138 | ): string { 139 | const characters = lettersOnly 140 | ? "abcdefghijklmnopqrstuvwxyz" 141 | : "abcdefghijklmnopqrstuvwxyz0123456789"; 142 | 143 | const bytes = randomBytes(length); 144 | let result = ""; 145 | 146 | for (let i = 0; i < length; i++) { 147 | result += characters[bytes[i]! % characters.length]; 148 | } 149 | 150 | return result; 151 | } 152 | 153 | /** 154 | * Convert ArrayBuffer to Base64 string 155 | */ 156 | export function arrayBufferToBase64(buffer: ArrayBuffer): string { 157 | return Buffer.from(buffer).toString("base64"); 158 | } 159 | 160 | /** 161 | * Convert Base64 string to Uint8Array 162 | */ 163 | export function base64ToBuffer(base64: string): Uint8Array { 164 | return base64ToUint8Array(base64); 165 | } 166 | -------------------------------------------------------------------------------- /packages/frontend/src/components/UrlManagerDialog.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 156 | -------------------------------------------------------------------------------- /packages/backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { DefineAPI, DefineEvents, SDK } from "caido:plugin"; 2 | 3 | import { initializeRSAKeys } from "./services/crypto"; 4 | import { 5 | clearAllData, 6 | clearInteractions, 7 | clearUrls, 8 | deleteInteraction, 9 | deleteInteractions, 10 | generateInteractshUrl, 11 | getActiveUrls, 12 | getClientCount, 13 | getFilter, 14 | getFilterEnabled, 15 | getInteractions, 16 | getInteractshStatus, 17 | getNewInteractions, 18 | initializeClients, 19 | pollInteractsh, 20 | removeUrl, 21 | setFilter, 22 | setFilterEnabled, 23 | setInteractionTag, 24 | setUrlActive, 25 | startInteractsh, 26 | stopInteractsh, 27 | } from "./services/interactshApi"; 28 | import { 29 | getSettings, 30 | resetSettings, 31 | updateSettings, 32 | } from "./services/settings"; 33 | 34 | export type API = DefineAPI<{ 35 | getSettings: typeof getSettings; 36 | updateSettings: typeof updateSettings; 37 | resetSettings: typeof resetSettings; 38 | startInteractsh: typeof startInteractsh; 39 | stopInteractsh: typeof stopInteractsh; 40 | generateInteractshUrl: typeof generateInteractshUrl; 41 | getInteractions: typeof getInteractions; 42 | getNewInteractions: typeof getNewInteractions; 43 | pollInteractsh: typeof pollInteractsh; 44 | clearInteractions: typeof clearInteractions; 45 | deleteInteraction: typeof deleteInteraction; 46 | deleteInteractions: typeof deleteInteractions; 47 | getInteractshStatus: typeof getInteractshStatus; 48 | getActiveUrls: typeof getActiveUrls; 49 | setUrlActive: typeof setUrlActive; 50 | removeUrl: typeof removeUrl; 51 | clearUrls: typeof clearUrls; 52 | initializeClients: typeof initializeClients; 53 | getClientCount: typeof getClientCount; 54 | clearAllData: typeof clearAllData; 55 | setFilter: typeof setFilter; 56 | getFilter: typeof getFilter; 57 | setFilterEnabled: typeof setFilterEnabled; 58 | getFilterEnabled: typeof getFilterEnabled; 59 | setInteractionTag: typeof setInteractionTag; 60 | }>; 61 | 62 | // Events that can be sent from backend to frontend 63 | export type BackendEvents = DefineEvents<{ 64 | onDataChanged: () => void; 65 | onUrlGenerated: (url: string) => void; 66 | onFilterChanged: (filter: string) => void; 67 | onFilterEnabledChanged: (enabled: boolean) => void; 68 | }>; 69 | 70 | let sdkInstance: SDK | undefined; 71 | 72 | export function getSDK(): SDK | undefined { 73 | return sdkInstance; 74 | } 75 | 76 | export function emitDataChanged(): void { 77 | if (sdkInstance) { 78 | sdkInstance.api.send("onDataChanged"); 79 | } 80 | } 81 | 82 | export function emitUrlGenerated(url: string): void { 83 | if (sdkInstance) { 84 | sdkInstance.api.send("onUrlGenerated", url); 85 | } 86 | } 87 | 88 | export function emitFilterChanged(filter: string): void { 89 | if (sdkInstance) { 90 | sdkInstance.api.send("onFilterChanged", filter); 91 | } 92 | } 93 | 94 | export function emitFilterEnabledChanged(enabled: boolean): void { 95 | if (sdkInstance) { 96 | sdkInstance.api.send("onFilterEnabledChanged", enabled); 97 | } 98 | } 99 | 100 | export function init(sdk: SDK) { 101 | sdkInstance = sdk; 102 | sdk.console.log("Initializing QuickSSRF backend"); 103 | 104 | // Pre-initialize RSA keys for faster first request 105 | sdk.console.log("Pre-initializing RSA keys..."); 106 | initializeRSAKeys(); 107 | sdk.console.log("RSA keys ready"); 108 | 109 | // Settings API 110 | sdk.api.register("getSettings", getSettings); 111 | sdk.api.register("updateSettings", updateSettings); 112 | sdk.api.register("resetSettings", resetSettings); 113 | 114 | // Interactsh API 115 | sdk.api.register("startInteractsh", startInteractsh); 116 | sdk.api.register("stopInteractsh", stopInteractsh); 117 | sdk.api.register("generateInteractshUrl", generateInteractshUrl); 118 | sdk.api.register("getInteractions", getInteractions); 119 | sdk.api.register("getNewInteractions", getNewInteractions); 120 | sdk.api.register("pollInteractsh", pollInteractsh); 121 | sdk.api.register("clearInteractions", clearInteractions); 122 | sdk.api.register("deleteInteraction", deleteInteraction); 123 | sdk.api.register("deleteInteractions", deleteInteractions); 124 | sdk.api.register("getInteractshStatus", getInteractshStatus); 125 | 126 | // URL Management API 127 | sdk.api.register("getActiveUrls", getActiveUrls); 128 | sdk.api.register("setUrlActive", setUrlActive); 129 | sdk.api.register("removeUrl", removeUrl); 130 | sdk.api.register("clearUrls", clearUrls); 131 | 132 | // Client Management API 133 | sdk.api.register("initializeClients", initializeClients); 134 | sdk.api.register("getClientCount", getClientCount); 135 | 136 | // Data Management API 137 | sdk.api.register("clearAllData", clearAllData); 138 | 139 | // Filter API 140 | sdk.api.register("setFilter", setFilter); 141 | sdk.api.register("getFilter", getFilter); 142 | sdk.api.register("setFilterEnabled", setFilterEnabled); 143 | sdk.api.register("getFilterEnabled", getFilterEnabled); 144 | 145 | // Tag API 146 | sdk.api.register("setInteractionTag", setInteractionTag); 147 | } 148 | -------------------------------------------------------------------------------- /packages/frontend/src/views/App.vue: -------------------------------------------------------------------------------- 1 | 75 | 142 | -------------------------------------------------------------------------------- /packages/frontend/src/components/SettingsDialog.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 168 | -------------------------------------------------------------------------------- /packages/frontend/src/composables/useLogic.ts: -------------------------------------------------------------------------------- 1 | import type { EditorView } from "@codemirror/view"; 2 | import { ref, watch } from "vue"; 3 | 4 | import { useSDK } from "@/plugins/sdk"; 5 | import { useEditorStore } from "@/stores/editorStore"; 6 | import { useInteractionStore } from "@/stores/interactionStore"; 7 | import { useUIStore } from "@/stores/uiStore"; 8 | 9 | // Get selected text from CodeMirror editor 10 | function getEditorSelectedText(editorView: EditorView): string { 11 | const state = editorView.state; 12 | const selection = state.selection.main; 13 | if (selection.from === selection.to) { 14 | return ""; 15 | } 16 | return state.sliceDoc(selection.from, selection.to); 17 | } 18 | 19 | export function useLogic() { 20 | const interactionStore = useInteractionStore(); 21 | const editorStore = useEditorStore(); 22 | const uiStore = useUIStore(); 23 | const sdk = useSDK(); 24 | 25 | const handleGenerateClick = async () => { 26 | uiStore.setGeneratingUrl(true); 27 | 28 | try { 29 | const url = await interactionStore.generateUrl(); 30 | 31 | if (!url) { 32 | sdk.window.showToast("Failed to generate URL.", { variant: "error" }); 33 | return null; 34 | } 35 | 36 | uiStore.setGeneratedUrl(url); 37 | return url; 38 | } finally { 39 | uiStore.setGeneratingUrl(false); 40 | } 41 | }; 42 | 43 | const handleManualPoll = async () => { 44 | uiStore.setPolling(true); 45 | // Poll for new interactions 46 | await interactionStore.manualPoll(); 47 | // Sync data and filter from backend 48 | await interactionStore.reloadData(); 49 | await interactionStore.loadFilter(); 50 | uiStore.setPolling(false); 51 | }; 52 | 53 | const handleClearData = () => { 54 | interactionStore.clearData(); 55 | editorStore.clearEditors(); 56 | uiStore.setSelectedRow(undefined); 57 | }; 58 | 59 | function waitForEditorRef() { 60 | return new Promise((resolve) => { 61 | const interval = setInterval(() => { 62 | if (editorStore.requestEditorRef && editorStore.responseEditorRef) { 63 | clearInterval(interval); 64 | resolve(); 65 | } 66 | }, 25); 67 | }); 68 | } 69 | 70 | const requestEl = ref(undefined); 71 | const responseEl = ref(undefined); 72 | const contextMenuRef = ref<{ show: (event: MouseEvent) => void }>(); 73 | const pendingCopyText = ref(""); 74 | 75 | const initializeEditors = () => { 76 | const responseEditor = sdk.ui.httpResponseEditor(); 77 | const requestEditor = sdk.ui.httpRequestEditor(); 78 | 79 | const responseEditorEl = responseEl.value?.appendChild( 80 | responseEditor.getElement(), 81 | ); 82 | const requestEditorEl = requestEl.value?.appendChild( 83 | requestEditor.getElement(), 84 | ); 85 | 86 | if (responseEditorEl && requestEditorEl) { 87 | editorStore.setResponseEditorRef(responseEditorEl); 88 | editorStore.setRequestEditorRef(requestEditorEl); 89 | } 90 | 91 | // Add context menu handlers 92 | const handleContextMenu = 93 | (editorView: EditorView) => (event: MouseEvent) => { 94 | const selectedText = getEditorSelectedText(editorView); 95 | if (selectedText && contextMenuRef.value) { 96 | event.preventDefault(); 97 | pendingCopyText.value = selectedText; 98 | contextMenuRef.value.show(event); 99 | } 100 | }; 101 | 102 | const requestContextHandler = handleContextMenu( 103 | requestEditor.getEditorView(), 104 | ); 105 | const responseContextHandler = handleContextMenu( 106 | responseEditor.getEditorView(), 107 | ); 108 | 109 | requestEditorEl?.addEventListener("contextmenu", requestContextHandler); 110 | responseEditorEl?.addEventListener("contextmenu", responseContextHandler); 111 | 112 | if (uiStore.selectedRow) { 113 | editorStore.updateEditorContent(uiStore.selectedRow); 114 | } 115 | 116 | const cleanWatch = watch( 117 | () => uiStore.selectedRow, 118 | (newValue) => { 119 | if (newValue) { 120 | editorStore.updateEditorContent(newValue); 121 | } 122 | }, 123 | ); 124 | 125 | const eventHandler = () => { 126 | waitForEditorRef().then(() => { 127 | cleanup(); 128 | initializeEditors(); 129 | }); 130 | }; 131 | 132 | const cleanup = () => { 133 | cleanWatch(); 134 | 135 | editorStore.eventBus.removeEventListener("refreshEditors", eventHandler); 136 | 137 | // Remove context menu handlers 138 | requestEditorEl?.removeEventListener( 139 | "contextmenu", 140 | requestContextHandler, 141 | ); 142 | responseEditorEl?.removeEventListener( 143 | "contextmenu", 144 | responseContextHandler, 145 | ); 146 | 147 | if (responseEditorEl) { 148 | responseEditorEl.remove(); 149 | } 150 | 151 | if (requestEditorEl) { 152 | requestEditorEl.remove(); 153 | } 154 | 155 | editorStore.requestEditorRef.value = undefined; 156 | editorStore.responseEditorRef.value = undefined; 157 | }; 158 | 159 | editorStore.eventBus.addEventListener("refreshEditors", eventHandler); 160 | }; 161 | 162 | const copySelectedText = () => { 163 | if (pendingCopyText.value) { 164 | navigator.clipboard.writeText(pendingCopyText.value); 165 | sdk.window.showToast("Copied to clipboard", { variant: "success" }); 166 | } 167 | }; 168 | 169 | return { 170 | requestEl, 171 | responseEl, 172 | contextMenuRef, 173 | pendingCopyText, 174 | 175 | handleGenerateClick, 176 | handleManualPoll, 177 | handleClearData, 178 | initializeEditors, 179 | copySelectedText, 180 | }; 181 | } 182 | -------------------------------------------------------------------------------- /packages/frontend/src/stores/settingsStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { ref } from "vue"; 3 | 4 | import { useInteractionStore } from "./interactionStore"; 5 | 6 | import { useSDK } from "@/plugins/sdk"; 7 | import { useUIStore } from "@/stores/uiStore"; 8 | 9 | export const SERVER_PRESETS = [ 10 | { label: "Random", value: "random" }, 11 | { label: "oast.site", value: "https://oast.site" }, 12 | { label: "oast.fun", value: "https://oast.fun" }, 13 | { label: "oast.me", value: "https://oast.me" }, 14 | { label: "oast.pro", value: "https://oast.pro" }, 15 | { label: "oast.live", value: "https://oast.live" }, 16 | { label: "Custom", value: "custom" }, 17 | ]; 18 | 19 | export const useSettingsStore = defineStore("settings", () => { 20 | const sdk = useSDK(); 21 | const interactionStore = useInteractionStore(); 22 | const uiStore = useUIStore(); 23 | 24 | const isDialogVisible = ref(false); 25 | const serverURL = ref(""); 26 | const serverMode = ref("https://oast.site"); // "random", "custom", or a preset URL 27 | const token = ref(""); 28 | const pollingInterval = ref(30_000); 29 | const correlationIdLength = ref(20); 30 | const correlationIdNonceLength = ref(13); 31 | const isSaving = ref(false); 32 | 33 | // Get list of actual server URLs (excluding random and custom) 34 | function getServerUrls(): string[] { 35 | return SERVER_PRESETS.filter( 36 | (p) => p.value !== "random" && p.value !== "custom", 37 | ).map((p) => p.value); 38 | } 39 | 40 | // Get the actual server URL to use (handles random mode) 41 | function getEffectiveServerUrl(): string { 42 | if (serverMode.value === "random") { 43 | const servers = getServerUrls(); 44 | const randomIndex = Math.floor(Math.random() * servers.length); 45 | return servers[randomIndex]!; 46 | } 47 | if (serverMode.value === "custom") { 48 | return serverURL.value; 49 | } 50 | return serverMode.value; 51 | } 52 | 53 | async function loadSettings() { 54 | try { 55 | const settings = await sdk.backend.getSettings(); 56 | serverURL.value = settings.serverURL; 57 | token.value = settings.token; 58 | pollingInterval.value = settings.pollingInterval; 59 | correlationIdLength.value = settings.correlationIdLength; 60 | correlationIdNonceLength.value = settings.correlationIdNonceLength; 61 | 62 | // Determine serverMode from serverURL 63 | if (settings.serverURL === "random") { 64 | serverMode.value = "random"; 65 | } else { 66 | const preset = SERVER_PRESETS.find( 67 | (p) => p.value === settings.serverURL, 68 | ); 69 | if (preset && preset.value !== "custom") { 70 | serverMode.value = preset.value; 71 | } else { 72 | serverMode.value = "custom"; 73 | } 74 | } 75 | } catch (error) { 76 | console.error(error); 77 | sdk.window.showToast("Failed to load settings", { variant: "error" }); 78 | } 79 | } 80 | 81 | async function saveSettings() { 82 | isSaving.value = true; 83 | try { 84 | const prevSettings = await sdk.backend.getSettings(); 85 | 86 | // Determine the URL to save based on mode 87 | const urlToSave = 88 | serverMode.value === "random" 89 | ? "random" 90 | : serverMode.value === "custom" 91 | ? serverURL.value 92 | : serverMode.value; 93 | 94 | const serverURLChanged = prevSettings.serverURL !== urlToSave; 95 | const correlationIdLengthChanged = 96 | prevSettings.correlationIdLength !== correlationIdLength.value; 97 | const correlationIdNonceLengthChanged = 98 | prevSettings.correlationIdNonceLength !== 99 | correlationIdNonceLength.value; 100 | const needsRestart = 101 | serverURLChanged || 102 | correlationIdLengthChanged || 103 | correlationIdNonceLengthChanged; 104 | 105 | await sdk.backend.updateSettings({ 106 | serverURL: urlToSave, 107 | token: token.value, 108 | pollingInterval: pollingInterval.value, 109 | correlationIdLength: correlationIdLength.value, 110 | correlationIdNonceLength: correlationIdNonceLength.value, 111 | }); 112 | 113 | if (needsRestart) { 114 | await interactionStore.resetClientService(); 115 | interactionStore.clearData(); 116 | uiStore.clearUI(); 117 | // Clear managed URLs since they belong to the old server configuration 118 | sdk.backend.clearUrls(); 119 | sdk.window.showToast("Settings changed. Please generate a new URL.", { 120 | variant: "info", 121 | }); 122 | } 123 | 124 | sdk.window.showToast("Settings saved successfully", { 125 | variant: "success", 126 | }); 127 | isDialogVisible.value = false; 128 | } catch (error) { 129 | console.error("Failed to save settings:", error); 130 | sdk.window.showToast("Failed to save settings", { variant: "error" }); 131 | } finally { 132 | isSaving.value = false; 133 | } 134 | } 135 | 136 | async function resetSettings() { 137 | try { 138 | const prevSettings = await sdk.backend.getSettings(); 139 | const needsRestart = 140 | prevSettings.serverURL !== "https://oast.site" || 141 | prevSettings.correlationIdLength !== 20 || 142 | prevSettings.correlationIdNonceLength !== 13; 143 | 144 | await sdk.backend.resetSettings(); 145 | await loadSettings(); 146 | 147 | if (needsRestart) { 148 | await interactionStore.resetClientService(); 149 | interactionStore.clearData(); 150 | uiStore.clearGeneratedUrl(); 151 | // Clear managed URLs since they belong to the old server configuration 152 | sdk.backend.clearUrls(); 153 | sdk.window.showToast("Settings reset. Please generate a new URL.", { 154 | variant: "info", 155 | }); 156 | } else { 157 | sdk.window.showToast("Settings reset successfully", { 158 | variant: "success", 159 | }); 160 | } 161 | } catch (error) { 162 | console.error("Failed to reset settings:", error); 163 | sdk.window.showToast("Failed to reset settings", { variant: "error" }); 164 | } 165 | } 166 | 167 | return { 168 | isDialogVisible, 169 | serverURL, 170 | serverMode, 171 | token, 172 | pollingInterval, 173 | correlationIdLength, 174 | correlationIdNonceLength, 175 | isSaving, 176 | loadSettings, 177 | saveSettings, 178 | resetSettings, 179 | getEffectiveServerUrl, 180 | }; 181 | }); 182 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ActionBar.vue: -------------------------------------------------------------------------------- 1 | 128 | 129 | 207 | -------------------------------------------------------------------------------- /packages/backend/src/services/crypto/aes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Pure JavaScript AES-256-CFB implementation for Caido backend 3 | * Based on the AES specification (FIPS-197) 4 | */ 5 | 6 | // AES S-box 7 | const SBOX: number[] = [ 8 | 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 9 | 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 10 | 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 11 | 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, 0x04, 0xc7, 0x23, 0xc3, 12 | 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, 0x09, 13 | 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 14 | 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 15 | 0x39, 0x4a, 0x4c, 0x58, 0xcf, 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 16 | 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 17 | 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 18 | 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 19 | 0x73, 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 20 | 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 21 | 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 22 | 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, 0xba, 0x78, 0x25, 23 | 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, 24 | 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 25 | 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 26 | 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 27 | 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, 28 | ]; 29 | 30 | // Round constants 31 | const RCON: number[] = [ 32 | 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 33 | ]; 34 | 35 | /** 36 | * Galois Field multiplication 37 | */ 38 | function gmul(a: number, b: number): number { 39 | let p = 0; 40 | for (let i = 0; i < 8; i++) { 41 | if (b & 1) { 42 | p ^= a; 43 | } 44 | const hiBitSet = a & 0x80; 45 | a = (a << 1) & 0xff; 46 | if (hiBitSet) { 47 | a ^= 0x1b; 48 | } 49 | b >>= 1; 50 | } 51 | return p; 52 | } 53 | 54 | /** 55 | * SubBytes transformation 56 | */ 57 | function subBytes(state: number[][]): void { 58 | for (let i = 0; i < 4; i++) { 59 | for (let j = 0; j < 4; j++) { 60 | state[i]![j] = SBOX[state[i]![j]!]!; 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * ShiftRows transformation 67 | */ 68 | function shiftRows(state: number[][]): void { 69 | // Row 1: shift left by 1 70 | const temp1 = state[1]![0]; 71 | state[1]![0] = state[1]![1]!; 72 | state[1]![1] = state[1]![2]!; 73 | state[1]![2] = state[1]![3]!; 74 | state[1]![3] = temp1!; 75 | 76 | // Row 2: shift left by 2 77 | const temp2a = state[2]![0]; 78 | const temp2b = state[2]![1]; 79 | state[2]![0] = state[2]![2]!; 80 | state[2]![1] = state[2]![3]!; 81 | state[2]![2] = temp2a!; 82 | state[2]![3] = temp2b!; 83 | 84 | // Row 3: shift left by 3 (= shift right by 1) 85 | const temp3 = state[3]![3]; 86 | state[3]![3] = state[3]![2]!; 87 | state[3]![2] = state[3]![1]!; 88 | state[3]![1] = state[3]![0]!; 89 | state[3]![0] = temp3!; 90 | } 91 | 92 | /** 93 | * MixColumns transformation 94 | */ 95 | function mixColumns(state: number[][]): void { 96 | for (let c = 0; c < 4; c++) { 97 | const a0 = state[0]![c]!; 98 | const a1 = state[1]![c]!; 99 | const a2 = state[2]![c]!; 100 | const a3 = state[3]![c]!; 101 | 102 | state[0]![c] = gmul(a0, 2) ^ gmul(a1, 3) ^ a2 ^ a3; 103 | state[1]![c] = a0 ^ gmul(a1, 2) ^ gmul(a2, 3) ^ a3; 104 | state[2]![c] = a0 ^ a1 ^ gmul(a2, 2) ^ gmul(a3, 3); 105 | state[3]![c] = gmul(a0, 3) ^ a1 ^ a2 ^ gmul(a3, 2); 106 | } 107 | } 108 | 109 | /** 110 | * AddRoundKey transformation 111 | */ 112 | function addRoundKey(state: number[][], roundKey: number[][]): void { 113 | for (let i = 0; i < 4; i++) { 114 | for (let j = 0; j < 4; j++) { 115 | const stateRow = state[i]; 116 | const roundKeyRow = roundKey[i]; 117 | if (stateRow && roundKeyRow) { 118 | stateRow[j] = (stateRow[j] ?? 0) ^ (roundKeyRow[j] ?? 0); 119 | } 120 | } 121 | } 122 | } 123 | 124 | /** 125 | * Key expansion for AES-256 (14 rounds) 126 | */ 127 | function keyExpansion(key: Uint8Array): number[][][] { 128 | const Nk = 8; // AES-256: 8 words 129 | const Nr = 14; // AES-256: 14 rounds 130 | const Nb = 4; 131 | 132 | const w: number[][] = []; 133 | 134 | // Copy the key into the first Nk words 135 | for (let i = 0; i < Nk; i++) { 136 | w[i] = [key[4 * i]!, key[4 * i + 1]!, key[4 * i + 2]!, key[4 * i + 3]!]; 137 | } 138 | 139 | // Generate the remaining words 140 | for (let i = Nk; i < Nb * (Nr + 1); i++) { 141 | const temp = [...w[i - 1]!]; 142 | 143 | if (i % Nk === 0) { 144 | // RotWord 145 | const t = temp[0]!; 146 | temp[0] = temp[1]!; 147 | temp[1] = temp[2]!; 148 | temp[2] = temp[3]!; 149 | temp[3] = t; 150 | 151 | // SubWord 152 | for (let j = 0; j < 4; j++) { 153 | temp[j] = SBOX[temp[j]!]!; 154 | } 155 | 156 | // XOR with Rcon 157 | temp[0] ^= RCON[i / Nk - 1]!; 158 | } else if (Nk > 6 && i % Nk === 4) { 159 | // SubWord for AES-256 160 | for (let j = 0; j < 4; j++) { 161 | temp[j] = SBOX[temp[j]!]!; 162 | } 163 | } 164 | 165 | w[i] = []; 166 | for (let j = 0; j < 4; j++) { 167 | w[i]![j] = w[i - Nk]![j]! ^ temp[j]!; 168 | } 169 | } 170 | 171 | // Convert to round keys (4x4 matrices) 172 | const roundKeys: number[][][] = []; 173 | for (let round = 0; round <= Nr; round++) { 174 | const roundKey: number[][] = [[], [], [], []]; 175 | for (let col = 0; col < 4; col++) { 176 | const word = w[round * 4 + col]!; 177 | for (let row = 0; row < 4; row++) { 178 | roundKey[row]![col] = word[row]!; 179 | } 180 | } 181 | roundKeys.push(roundKey); 182 | } 183 | 184 | return roundKeys; 185 | } 186 | 187 | /** 188 | * AES block encryption (single 16-byte block) 189 | */ 190 | function aesEncryptBlock( 191 | block: Uint8Array, 192 | roundKeys: number[][][], 193 | ): Uint8Array { 194 | const Nr = 14; // AES-256 195 | 196 | // Initialize state from block (column-major order) 197 | const state: number[][] = [[], [], [], []]; 198 | for (let col = 0; col < 4; col++) { 199 | for (let row = 0; row < 4; row++) { 200 | state[row]![col] = block[col * 4 + row]!; 201 | } 202 | } 203 | 204 | // Initial round key addition 205 | addRoundKey(state, roundKeys[0]!); 206 | 207 | // Main rounds 208 | for (let round = 1; round < Nr; round++) { 209 | subBytes(state); 210 | shiftRows(state); 211 | mixColumns(state); 212 | addRoundKey(state, roundKeys[round]!); 213 | } 214 | 215 | // Final round (no MixColumns) 216 | subBytes(state); 217 | shiftRows(state); 218 | addRoundKey(state, roundKeys[Nr]!); 219 | 220 | // Convert state back to output block (column-major order) 221 | const output = new Uint8Array(16); 222 | for (let col = 0; col < 4; col++) { 223 | for (let row = 0; row < 4; row++) { 224 | output[col * 4 + row] = state[row]![col]!; 225 | } 226 | } 227 | 228 | return output; 229 | } 230 | 231 | /** 232 | * AES-256-CFB decryption 233 | * CFB mode: plaintext = ciphertext XOR AES(previousCiphertext) 234 | */ 235 | export function aesCfbDecrypt( 236 | key: Uint8Array, 237 | iv: Uint8Array, 238 | ciphertext: Uint8Array, 239 | ): Uint8Array { 240 | if (key.length !== 32) { 241 | throw new Error("AES-256 requires a 32-byte key"); 242 | } 243 | if (iv.length !== 16) { 244 | throw new Error("AES requires a 16-byte IV"); 245 | } 246 | 247 | const roundKeys = keyExpansion(key); 248 | const plaintext = new Uint8Array(ciphertext.length); 249 | 250 | let previousBlock = iv; 251 | 252 | for (let i = 0; i < ciphertext.length; i += 16) { 253 | // Encrypt the previous ciphertext block (or IV for first block) 254 | const encryptedBlock = aesEncryptBlock(previousBlock, roundKeys); 255 | 256 | // XOR with current ciphertext to get plaintext 257 | const blockSize = Math.min(16, ciphertext.length - i); 258 | for (let j = 0; j < blockSize; j++) { 259 | plaintext[i + j] = ciphertext[i + j]! ^ encryptedBlock[j]!; 260 | } 261 | 262 | // The previous block for CFB is the ciphertext block (not the plaintext) 263 | if (blockSize === 16) { 264 | previousBlock = ciphertext.slice(i, i + 16); 265 | } else { 266 | // Last partial block 267 | const newPrevBlock = new Uint8Array(16); 268 | newPrevBlock.set(ciphertext.slice(i, i + blockSize)); 269 | previousBlock = newPrevBlock; 270 | } 271 | } 272 | 273 | return plaintext; 274 | } 275 | 276 | /** 277 | * AES-256-CFB encryption (for testing/completeness) 278 | */ 279 | export function aesCfbEncrypt( 280 | key: Uint8Array, 281 | iv: Uint8Array, 282 | plaintext: Uint8Array, 283 | ): Uint8Array { 284 | if (key.length !== 32) { 285 | throw new Error("AES-256 requires a 32-byte key"); 286 | } 287 | if (iv.length !== 16) { 288 | throw new Error("AES requires a 16-byte IV"); 289 | } 290 | 291 | const roundKeys = keyExpansion(key); 292 | const ciphertext = new Uint8Array(plaintext.length); 293 | 294 | let previousBlock = iv; 295 | 296 | for (let i = 0; i < plaintext.length; i += 16) { 297 | // Encrypt the previous ciphertext block (or IV for first block) 298 | const encryptedBlock = aesEncryptBlock(previousBlock, roundKeys); 299 | 300 | // XOR with current plaintext to get ciphertext 301 | const blockSize = Math.min(16, plaintext.length - i); 302 | for (let j = 0; j < blockSize; j++) { 303 | ciphertext[i + j] = plaintext[i + j]! ^ encryptedBlock[j]!; 304 | } 305 | 306 | // The previous block for CFB is the ciphertext block 307 | if (blockSize === 16) { 308 | previousBlock = ciphertext.slice(i, i + 16); 309 | } else { 310 | const newPrevBlock = new Uint8Array(16); 311 | newPrevBlock.set(ciphertext.slice(i, i + blockSize)); 312 | previousBlock = newPrevBlock; 313 | } 314 | } 315 | 316 | return ciphertext; 317 | } 318 | -------------------------------------------------------------------------------- /packages/backend/src/services/interactsh.ts: -------------------------------------------------------------------------------- 1 | import { Blob, fetch, type Response } from "caido:http"; 2 | import type { SDK } from "caido:plugin"; 3 | 4 | import { 5 | areKeysInitialized, 6 | decryptMessage, 7 | encodePublicKey, 8 | generateRandomString, 9 | initializeRSAKeys, 10 | } from "./crypto"; 11 | 12 | /** 13 | * Enum representing the possible states of the Interactsh client 14 | */ 15 | enum State { 16 | Idle, 17 | Polling, 18 | Closed, 19 | } 20 | 21 | /** 22 | * Configuration options for the Interactsh client 23 | */ 24 | interface Options { 25 | serverURL: string; 26 | token: string; 27 | correlationIdLength?: number; 28 | correlationIdNonceLength?: number; 29 | sessionInfo?: SessionInfo; 30 | keepAliveInterval?: number; 31 | } 32 | 33 | /** 34 | * Session information for restoring a previous Interactsh session 35 | */ 36 | interface SessionInfo { 37 | serverURL: string; 38 | token: string; 39 | correlationID: string; 40 | secretKey: string; 41 | } 42 | 43 | /** 44 | * Interaction data from Interactsh server 45 | */ 46 | interface InteractionData { 47 | protocol: string; 48 | "unique-id": string; 49 | "full-id": string; 50 | "raw-request"?: string; 51 | "raw-response"?: string; 52 | "remote-address"?: string; 53 | timestamp: string; 54 | [key: string]: unknown; 55 | } 56 | 57 | /** 58 | * Simple URL parser since URL is not available in Caido backend 59 | */ 60 | function parseUrl(urlString: string): { host: string; origin: string } { 61 | // Extract protocol and host from URL string 62 | const protocolMatch = urlString.match(/^(https?:\/\/)/); 63 | const protocol = protocolMatch ? protocolMatch[1] : "https://"; 64 | const withoutProtocol = urlString.replace(/^https?:\/\//, ""); 65 | const hostMatch = withoutProtocol.match(/^([^/]+)/); 66 | const host = hostMatch ? hostMatch[1]! : withoutProtocol; 67 | return { 68 | host, 69 | origin: `${protocol}${host}`, 70 | }; 71 | } 72 | 73 | /** 74 | * Construct a full URL from base and path 75 | */ 76 | function buildUrl(base: string, path: string): string { 77 | const { origin } = parseUrl(base); 78 | return `${origin}${path}`; 79 | } 80 | 81 | /** 82 | * Creates and returns an Interactsh client service for Caido backend 83 | */ 84 | export const createInteractshClient = (sdk: SDK) => { 85 | let state: State = State.Idle; 86 | let correlationID: string | undefined; 87 | let secretKey: string | undefined; 88 | let serverURLString: string | undefined; 89 | let serverHost: string | undefined; 90 | let token: string | undefined; 91 | let quitPollingFlag = false; 92 | let pollingInterval = 5000; 93 | let correlationIdNonceLength = 13; 94 | let interactionCallback: ((interaction: InteractionData) => void) | undefined; 95 | 96 | const defaultInteractionHandler = () => {}; 97 | 98 | /** 99 | * Ensure RSA keys are initialized 100 | */ 101 | const ensureKeysInitialized = (): void => { 102 | if (!areKeysInitialized()) { 103 | sdk.console.log("Initializing RSA keys..."); 104 | initializeRSAKeys(); 105 | sdk.console.log("RSA keys initialized"); 106 | } 107 | }; 108 | 109 | /** 110 | * HTTP request options 111 | */ 112 | interface HttpRequestOptions { 113 | method?: string; 114 | body?: string; 115 | headers?: Record; 116 | } 117 | 118 | /** 119 | * Make an HTTP request using fetch 120 | */ 121 | const httpRequest = async ( 122 | url: string, 123 | options: HttpRequestOptions = {}, 124 | ): Promise => { 125 | const headers: Record = { 126 | "Content-Type": "application/json", 127 | ...(options.headers || {}), 128 | }; 129 | 130 | if (token) { 131 | headers["Authorization"] = token; 132 | } 133 | 134 | return fetch(url, { 135 | method: options.method, 136 | body: options.body ? new Blob([options.body]) : undefined, 137 | headers, 138 | }); 139 | }; 140 | 141 | /** 142 | * Registers the client with the Interactsh server 143 | */ 144 | const performRegistration = async (payload: object): Promise => { 145 | if (!serverURLString) { 146 | throw new Error("Server URL is not defined"); 147 | } 148 | 149 | const url = buildUrl(serverURLString, "/register"); 150 | 151 | try { 152 | const response = await httpRequest(url, { 153 | method: "POST", 154 | body: JSON.stringify(payload), 155 | }); 156 | 157 | if (response.status === 200) { 158 | state = State.Idle; 159 | sdk.console.log("Successfully registered with Interactsh server"); 160 | } else { 161 | const errorText = await response.text(); 162 | throw new Error(`Registration failed: ${errorText}`); 163 | } 164 | } catch (error) { 165 | sdk.console.error(`Registration error: ${error}`); 166 | throw new Error( 167 | "Registration failed, please check your server URL and token", 168 | ); 169 | } 170 | }; 171 | 172 | /** 173 | * Fetches interactions from the Interactsh server 174 | */ 175 | const getInteractions = async ( 176 | callback: (interaction: InteractionData) => void, 177 | ): Promise => { 178 | if (!correlationID || !secretKey || !serverURLString) { 179 | throw new Error("Missing required client configuration"); 180 | } 181 | 182 | const url = buildUrl( 183 | serverURLString, 184 | `/poll?id=${correlationID}&secret=${secretKey}`, 185 | ); 186 | 187 | try { 188 | const response = await httpRequest(url, { method: "GET" }); 189 | 190 | if (response.status !== 200) { 191 | if (response.status === 401) { 192 | throw new Error("Couldn't authenticate to the server"); 193 | } 194 | const errorText = await response.text(); 195 | throw new Error(`Could not poll interactions: ${errorText}`); 196 | } 197 | 198 | const data = (await response.json()) as { 199 | data?: string[]; 200 | aes_key?: string; 201 | }; 202 | 203 | if (data?.data && Array.isArray(data.data) && data.aes_key) { 204 | for (const item of data.data) { 205 | try { 206 | const decryptedData = decryptMessage(data.aes_key, item); 207 | const interaction = JSON.parse(decryptedData) as InteractionData; 208 | callback(interaction); 209 | } catch (err) { 210 | sdk.console.error(`Failed to decrypt/parse interaction: ${err}`); 211 | } 212 | } 213 | } 214 | } catch (error) { 215 | sdk.console.error(`Error polling interactions: ${error}`); 216 | throw new Error("Error polling interactions"); 217 | } 218 | }; 219 | 220 | /** 221 | * Initializes the Interactsh client with the provided options 222 | */ 223 | const initialize = async ( 224 | options: Options, 225 | interactionCallbackParam?: (interaction: InteractionData) => void, 226 | ): Promise => { 227 | ensureKeysInitialized(); 228 | 229 | token = options.token; 230 | correlationID = 231 | options.sessionInfo?.correlationID || 232 | generateRandomString(options.correlationIdLength || 20); 233 | secretKey = 234 | options.sessionInfo?.secretKey || 235 | generateRandomString(options.correlationIdNonceLength || 13); 236 | 237 | serverURLString = options.serverURL; 238 | const parsed = parseUrl(options.serverURL); 239 | serverHost = parsed.host; 240 | 241 | correlationIdNonceLength = options.correlationIdNonceLength || 13; 242 | 243 | if (interactionCallbackParam) { 244 | interactionCallback = interactionCallbackParam; 245 | } 246 | 247 | if (options.sessionInfo) { 248 | const { token: sessionToken, serverURL: sessionServerURL } = 249 | options.sessionInfo; 250 | token = sessionToken; 251 | serverURLString = sessionServerURL; 252 | const parsedSession = parseUrl(sessionServerURL); 253 | serverHost = parsedSession.host; 254 | } 255 | 256 | const publicKey = encodePublicKey(); 257 | await performRegistration({ 258 | "public-key": publicKey, 259 | "secret-key": secretKey, 260 | "correlation-id": correlationID, 261 | }); 262 | 263 | if (options.keepAliveInterval) { 264 | pollingInterval = options.keepAliveInterval; 265 | startPolling(interactionCallback || defaultInteractionHandler); 266 | } 267 | }; 268 | 269 | /** 270 | * Starts polling the server for interactions 271 | */ 272 | const startPolling = ( 273 | callback: (interaction: InteractionData) => void, 274 | ): void => { 275 | if (state === State.Polling) { 276 | throw new Error("Client is already polling"); 277 | } 278 | 279 | quitPollingFlag = false; 280 | state = State.Polling; 281 | 282 | const pollingLoop = async () => { 283 | while (!quitPollingFlag) { 284 | try { 285 | await getInteractions(callback); 286 | } catch (err) { 287 | sdk.console.error(`Polling error: ${err}`); 288 | } 289 | await new Promise((resolve) => setTimeout(resolve, pollingInterval)); 290 | } 291 | }; 292 | 293 | pollingLoop(); 294 | }; 295 | 296 | /** 297 | * Manually polls the server once for interactions 298 | */ 299 | const poll = async (): Promise => { 300 | if (state !== State.Polling) { 301 | throw new Error("Client is not polling"); 302 | } 303 | 304 | await getInteractions(interactionCallback || defaultInteractionHandler); 305 | }; 306 | 307 | /** 308 | * Stops the polling process 309 | */ 310 | const stopPolling = (): void => { 311 | if (state !== State.Polling) { 312 | throw new Error("Client is not polling"); 313 | } 314 | 315 | quitPollingFlag = true; 316 | state = State.Idle; 317 | }; 318 | 319 | /** 320 | * Sets the polling interval in seconds 321 | */ 322 | const setRefreshTimeSecond = (seconds: number): void => { 323 | if (seconds < 5 || seconds > 3600) { 324 | throw new Error( 325 | "The polling interval must be between 5 and 3600 seconds", 326 | ); 327 | } 328 | pollingInterval = seconds * 1000; 329 | 330 | if (state === State.Polling) { 331 | stopPolling(); 332 | startPolling(interactionCallback || defaultInteractionHandler); 333 | } 334 | }; 335 | 336 | /** 337 | * Updates the polling interval in milliseconds 338 | */ 339 | const updatePollingInterval = (ms: number): void => { 340 | const seconds = Math.floor(ms / 1000); 341 | setRefreshTimeSecond(seconds); 342 | }; 343 | 344 | /** 345 | * Deregisters the client from the Interactsh server 346 | */ 347 | const close = async (): Promise => { 348 | if (state === State.Polling) { 349 | throw new Error("Client should stop polling before closing"); 350 | } 351 | if (state === State.Closed) { 352 | throw new Error("Client is already closed"); 353 | } 354 | 355 | if (!serverURLString) { 356 | throw new Error("Server URL is not defined"); 357 | } 358 | 359 | const url = buildUrl(serverURLString, "/deregister"); 360 | 361 | try { 362 | const response = await httpRequest(url, { 363 | method: "POST", 364 | body: JSON.stringify({ 365 | correlationID: correlationID, 366 | secretKey: secretKey, 367 | }), 368 | }); 369 | 370 | if (response.status !== 200) { 371 | const errorText = await response.text(); 372 | throw new Error(`Could not deregister from server: ${errorText}`); 373 | } 374 | 375 | state = State.Closed; 376 | sdk.console.log("Successfully deregistered from Interactsh server"); 377 | } catch (error) { 378 | sdk.console.error(`Failed to deregister: ${error}`); 379 | throw new Error("Could not deregister from server"); 380 | } 381 | }; 382 | 383 | /** 384 | * Starts the Interactsh client with the provided options 385 | */ 386 | const start = async ( 387 | options: Options, 388 | interactionCallbackParam?: (interaction: InteractionData) => void, 389 | ): Promise => { 390 | await initialize(options, interactionCallbackParam); 391 | }; 392 | 393 | /** 394 | * Stops polling and closes the client 395 | */ 396 | const stop = async (): Promise => { 397 | if (state === State.Polling) { 398 | stopPolling(); 399 | } 400 | await close(); 401 | }; 402 | 403 | /** 404 | * Generates a unique URL for the current session 405 | */ 406 | const generateUrl = ( 407 | incrementNumber = 0, 408 | ): { url: string; uniqueId: string } => { 409 | if (state === State.Closed || !correlationID || !serverHost) { 410 | return { url: "", uniqueId: "" }; 411 | } 412 | 413 | const randomId = generateRandomString(correlationIdNonceLength); 414 | const url = `https://${correlationID}${randomId}.${serverHost}`; 415 | const uniqueId = `${correlationID}${randomId}`; 416 | return { url, uniqueId }; 417 | }; 418 | 419 | /** 420 | * Get current state 421 | */ 422 | const getState = (): State => state; 423 | 424 | /** 425 | * Get correlation ID 426 | */ 427 | const getCorrelationID = (): string | undefined => correlationID; 428 | 429 | return { 430 | getState, 431 | getCorrelationID, 432 | start, 433 | generateUrl, 434 | poll, 435 | setRefreshTimeSecond, 436 | updatePollingInterval, 437 | stop, 438 | close, 439 | }; 440 | }; 441 | 442 | export type InteractshClient = ReturnType; 443 | -------------------------------------------------------------------------------- /packages/backend/src/services/crypto/rsa.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Pure JavaScript RSA-OAEP implementation for Caido backend 3 | * Uses native BigInt for large number arithmetic 4 | */ 5 | 6 | import { Buffer } from "buffer"; 7 | import { createHash, randomBytes } from "crypto"; 8 | 9 | /** 10 | * RSA Key Pair interface 11 | */ 12 | export interface RSAKeyPair { 13 | publicKey: RSAPublicKey; 14 | privateKey: RSAPrivateKey; 15 | } 16 | 17 | export interface RSAPublicKey { 18 | n: bigint; // modulus 19 | e: bigint; // public exponent 20 | } 21 | 22 | export interface RSAPrivateKey { 23 | n: bigint; // modulus 24 | d: bigint; // private exponent 25 | p: bigint; // prime 1 26 | q: bigint; // prime 2 27 | dp: bigint; // d mod (p-1) 28 | dq: bigint; // d mod (q-1) 29 | qi: bigint; // q^(-1) mod p 30 | } 31 | 32 | /** 33 | * Convert Uint8Array to BigInt 34 | */ 35 | function uint8ArrayToBigInt(bytes: Uint8Array): bigint { 36 | let result = 0n; 37 | for (const byte of bytes) { 38 | result = (result << 8n) | BigInt(byte); 39 | } 40 | return result; 41 | } 42 | 43 | /** 44 | * Convert BigInt to Uint8Array with specified length 45 | */ 46 | function bigIntToUint8Array(num: bigint, length: number): Uint8Array { 47 | const result = new Uint8Array(length); 48 | let temp = num; 49 | for (let i = length - 1; i >= 0; i--) { 50 | result[i] = Number(temp & 0xffn); 51 | temp >>= 8n; 52 | } 53 | return result; 54 | } 55 | 56 | /** 57 | * Miller-Rabin primality test 58 | */ 59 | function isProbablePrime(n: bigint, k: number = 20): boolean { 60 | if (n < 2n) return false; 61 | if (n === 2n || n === 3n) return true; 62 | if (n % 2n === 0n) return false; 63 | 64 | // Write n-1 as 2^r * d 65 | let r = 0n; 66 | let d = n - 1n; 67 | while (d % 2n === 0n) { 68 | d /= 2n; 69 | r++; 70 | } 71 | 72 | // Witness loop 73 | witnessLoop: for (let i = 0; i < k; i++) { 74 | // Random a in [2, n-2] 75 | const aBytes = randomBytes(32); 76 | const a = (uint8ArrayToBigInt(aBytes) % (n - 4n)) + 2n; 77 | 78 | let x = modPow(a, d, n); 79 | 80 | if (x === 1n || x === n - 1n) continue; 81 | 82 | for (let j = 0n; j < r - 1n; j++) { 83 | x = modPow(x, 2n, n); 84 | if (x === n - 1n) continue witnessLoop; 85 | } 86 | 87 | return false; 88 | } 89 | 90 | return true; 91 | } 92 | 93 | /** 94 | * Modular exponentiation using square-and-multiply 95 | */ 96 | function modPow(base: bigint, exp: bigint, mod: bigint): bigint { 97 | let result = 1n; 98 | base = base % mod; 99 | 100 | while (exp > 0n) { 101 | if (exp % 2n === 1n) { 102 | result = (result * base) % mod; 103 | } 104 | exp = exp >> 1n; 105 | base = (base * base) % mod; 106 | } 107 | 108 | return result; 109 | } 110 | 111 | /** 112 | * Extended Euclidean Algorithm 113 | */ 114 | function extendedGcd( 115 | a: bigint, 116 | b: bigint, 117 | ): { gcd: bigint; x: bigint; y: bigint } { 118 | if (a === 0n) { 119 | return { gcd: b, x: 0n, y: 1n }; 120 | } 121 | 122 | const { gcd, x, y } = extendedGcd(b % a, a); 123 | return { 124 | gcd, 125 | x: y - (b / a) * x, 126 | y: x, 127 | }; 128 | } 129 | 130 | /** 131 | * Modular multiplicative inverse 132 | */ 133 | function modInverse(a: bigint, m: bigint): bigint { 134 | const { gcd, x } = extendedGcd(a % m, m); 135 | if (gcd !== 1n) { 136 | throw new Error("Modular inverse does not exist"); 137 | } 138 | return ((x % m) + m) % m; 139 | } 140 | 141 | /** 142 | * Generate a random prime of specified bit length 143 | */ 144 | function generatePrime(bits: number): bigint { 145 | while (true) { 146 | const bytes = randomBytes(Math.ceil(bits / 8)); 147 | 148 | // Set the high bit to ensure the number has the right bit length 149 | bytes[0]! |= 0x80; 150 | 151 | // Set the low bit to ensure the number is odd 152 | bytes[bytes.length - 1]! |= 0x01; 153 | 154 | const candidate = uint8ArrayToBigInt(bytes); 155 | 156 | if (isProbablePrime(candidate)) { 157 | return candidate; 158 | } 159 | } 160 | } 161 | 162 | /** 163 | * Generate RSA-2048 key pair 164 | */ 165 | export function generateRSAKeyPair(): RSAKeyPair { 166 | const bits = 1024; // Each prime is 1024 bits for RSA-2048 167 | 168 | // Generate two distinct primes 169 | const p = generatePrime(bits); 170 | let q = generatePrime(bits); 171 | 172 | while (p === q) { 173 | q = generatePrime(bits); 174 | } 175 | 176 | const n = p * q; 177 | const phi = (p - 1n) * (q - 1n); 178 | 179 | // Standard public exponent 180 | const e = 65537n; 181 | 182 | // Private exponent 183 | const d = modInverse(e, phi); 184 | 185 | // CRT components for faster decryption 186 | const dp = d % (p - 1n); 187 | const dq = d % (q - 1n); 188 | const qi = modInverse(q, p); 189 | 190 | return { 191 | publicKey: { n, e }, 192 | privateKey: { n, d, p, q, dp, dq, qi }, 193 | }; 194 | } 195 | 196 | /** 197 | * MGF1 (Mask Generation Function) using SHA-256 198 | */ 199 | function mgf1Sha256(seed: Uint8Array, length: number): Uint8Array { 200 | const result = new Uint8Array(length); 201 | const hashLen = 32; // SHA-256 output length 202 | let offset = 0; 203 | let counter = 0; 204 | 205 | while (offset < length) { 206 | const counterBytes = new Uint8Array(4); 207 | counterBytes[0] = (counter >> 24) & 0xff; 208 | counterBytes[1] = (counter >> 16) & 0xff; 209 | counterBytes[2] = (counter >> 8) & 0xff; 210 | counterBytes[3] = counter & 0xff; 211 | 212 | const data = new Uint8Array(seed.length + 4); 213 | data.set(seed); 214 | data.set(counterBytes, seed.length); 215 | 216 | const hash = createHash("sha256").update(data).digest(); 217 | const copyLen = Math.min(hashLen, length - offset); 218 | 219 | for (let i = 0; i < copyLen; i++) { 220 | result[offset + i] = hash[i]!; 221 | } 222 | 223 | offset += copyLen; 224 | counter++; 225 | } 226 | 227 | return result; 228 | } 229 | 230 | /** 231 | * SHA-256 hash 232 | */ 233 | function sha256(data: Uint8Array): Uint8Array { 234 | const hash = createHash("sha256").update(data).digest(); 235 | return new Uint8Array(hash); 236 | } 237 | 238 | /** 239 | * OAEP decoding (RSA-OAEP with SHA-256) 240 | */ 241 | function oaepDecode( 242 | em: Uint8Array, 243 | label: Uint8Array = new Uint8Array(0), 244 | ): Uint8Array { 245 | const hLen = 32; // SHA-256 hash length 246 | const k = em.length; // Key length in bytes 247 | 248 | if (k < 2 * hLen + 2) { 249 | throw new Error("Decryption error: message too short"); 250 | } 251 | 252 | // Split EM into Y || maskedSeed || maskedDB 253 | const y = em[0]; 254 | const maskedSeed = em.slice(1, 1 + hLen); 255 | const maskedDB = em.slice(1 + hLen); 256 | 257 | // Generate seedMask 258 | const seedMask = mgf1Sha256(maskedDB, hLen); 259 | 260 | // Recover seed 261 | const seed = new Uint8Array(hLen); 262 | for (let i = 0; i < hLen; i++) { 263 | seed[i] = maskedSeed[i]! ^ seedMask[i]!; 264 | } 265 | 266 | // Generate dbMask 267 | const dbMask = mgf1Sha256(seed, k - hLen - 1); 268 | 269 | // Recover DB 270 | const db = new Uint8Array(k - hLen - 1); 271 | for (let i = 0; i < db.length; i++) { 272 | db[i] = maskedDB[i]! ^ dbMask[i]!; 273 | } 274 | 275 | // Split DB into lHash' || PS || 0x01 || M 276 | const lHash = sha256(label); 277 | const lHashPrime = db.slice(0, hLen); 278 | 279 | // Verify lHash 280 | let valid = y === 0; 281 | for (let i = 0; i < hLen; i++) { 282 | if (lHash[i] !== lHashPrime[i]) { 283 | valid = false; 284 | } 285 | } 286 | 287 | // Find the 0x01 separator 288 | let separatorIndex = -1; 289 | for (let i = hLen; i < db.length; i++) { 290 | if (db[i] === 0x01) { 291 | separatorIndex = i; 292 | break; 293 | } else if (db[i] !== 0x00) { 294 | valid = false; 295 | break; 296 | } 297 | } 298 | 299 | if (!valid || separatorIndex === -1) { 300 | throw new Error("Decryption error: invalid OAEP padding"); 301 | } 302 | 303 | // Extract message 304 | return db.slice(separatorIndex + 1); 305 | } 306 | 307 | /** 308 | * RSA decryption using CRT for efficiency 309 | */ 310 | function rsaDecryptRaw(ciphertext: bigint, privateKey: RSAPrivateKey): bigint { 311 | const { p, q, dp, dq, qi } = privateKey; 312 | 313 | // CRT decryption 314 | const m1 = modPow(ciphertext % p, dp, p); 315 | const m2 = modPow(ciphertext % q, dq, q); 316 | 317 | let h = (qi * ((m1 - m2 + p) % p)) % p; 318 | if (h < 0n) h += p; 319 | 320 | return m2 + h * q; 321 | } 322 | 323 | /** 324 | * RSA-OAEP decryption with SHA-256 325 | */ 326 | export function rsaOaepDecrypt( 327 | ciphertext: Uint8Array, 328 | privateKey: RSAPrivateKey, 329 | label: Uint8Array = new Uint8Array(0), 330 | ): Uint8Array { 331 | const k = Math.ceil(bigIntLength(privateKey.n) / 8); 332 | 333 | if (ciphertext.length !== k) { 334 | throw new Error( 335 | `Decryption error: ciphertext length (${ciphertext.length}) != key length (${k})`, 336 | ); 337 | } 338 | 339 | // Convert ciphertext to integer 340 | const c = uint8ArrayToBigInt(ciphertext); 341 | 342 | // RSA decryption 343 | const m = rsaDecryptRaw(c, privateKey); 344 | 345 | // Convert to byte array 346 | const em = bigIntToUint8Array(m, k); 347 | 348 | // OAEP decode 349 | return oaepDecode(em, label); 350 | } 351 | 352 | /** 353 | * Get bit length of a BigInt 354 | */ 355 | function bigIntLength(n: bigint): number { 356 | let bits = 0; 357 | let temp = n; 358 | while (temp > 0n) { 359 | bits++; 360 | temp >>= 1n; 361 | } 362 | return bits; 363 | } 364 | 365 | /** 366 | * Export public key to SPKI PEM format 367 | */ 368 | export function exportPublicKeyPEM(publicKey: RSAPublicKey): string { 369 | // ASN.1 DER encoding of RSA public key in SPKI format 370 | const nBytes = bigIntToUint8Array(publicKey.n, 256); // 2048 bits = 256 bytes 371 | const eBytes = bigIntToUint8Array(publicKey.e, 3); // 65537 fits in 3 bytes 372 | 373 | // Build the RSAPublicKey SEQUENCE 374 | const rsaPublicKey = asn1Sequence([asn1Integer(nBytes), asn1Integer(eBytes)]); 375 | 376 | // OID for rsaEncryption: 1.2.840.113549.1.1.1 377 | const rsaOid = new Uint8Array([ 378 | 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 379 | ]); 380 | 381 | // AlgorithmIdentifier SEQUENCE 382 | const algorithmIdentifier = asn1Sequence([ 383 | rsaOid, 384 | new Uint8Array([0x05, 0x00]), 385 | ]); // NULL 386 | 387 | // BIT STRING wrapper for the public key 388 | const bitString = asn1BitString(rsaPublicKey); 389 | 390 | // SubjectPublicKeyInfo SEQUENCE 391 | const spki = asn1Sequence([algorithmIdentifier, bitString]); 392 | 393 | // Convert to base64 PEM 394 | const base64 = uint8ArrayToBase64(spki); 395 | const lines = base64.match(/.{1,64}/g) || []; 396 | 397 | return `-----BEGIN PUBLIC KEY-----\n${lines.join("\n")}\n-----END PUBLIC KEY-----`; 398 | } 399 | 400 | /** 401 | * ASN.1 DER encoding helpers 402 | */ 403 | function asn1Length(length: number): Uint8Array { 404 | if (length < 128) { 405 | return new Uint8Array([length]); 406 | } else if (length < 256) { 407 | return new Uint8Array([0x81, length]); 408 | } else if (length < 65536) { 409 | return new Uint8Array([0x82, (length >> 8) & 0xff, length & 0xff]); 410 | } else { 411 | throw new Error("Length too long for ASN.1 encoding"); 412 | } 413 | } 414 | 415 | function asn1Integer(bytes: Uint8Array): Uint8Array { 416 | // Remove leading zeros, but keep one if the high bit is set 417 | let start = 0; 418 | while (start < bytes.length - 1 && bytes[start] === 0) { 419 | start++; 420 | } 421 | 422 | // Add a leading zero if high bit is set (to indicate positive number) 423 | const needsLeadingZero = (bytes[start]! & 0x80) !== 0; 424 | const contentLength = bytes.length - start + (needsLeadingZero ? 1 : 0); 425 | 426 | const lengthBytes = asn1Length(contentLength); 427 | const result = new Uint8Array(1 + lengthBytes.length + contentLength); 428 | 429 | result[0] = 0x02; // INTEGER tag 430 | result.set(lengthBytes, 1); 431 | 432 | let offset = 1 + lengthBytes.length; 433 | if (needsLeadingZero) { 434 | result[offset] = 0x00; 435 | offset++; 436 | } 437 | result.set(bytes.slice(start), offset); 438 | 439 | return result; 440 | } 441 | 442 | function asn1Sequence(elements: Uint8Array[]): Uint8Array { 443 | const totalLength = elements.reduce((sum, el) => sum + el.length, 0); 444 | const lengthBytes = asn1Length(totalLength); 445 | 446 | const result = new Uint8Array(1 + lengthBytes.length + totalLength); 447 | result[0] = 0x30; // SEQUENCE tag 448 | result.set(lengthBytes, 1); 449 | 450 | let offset = 1 + lengthBytes.length; 451 | for (const element of elements) { 452 | result.set(element, offset); 453 | offset += element.length; 454 | } 455 | 456 | return result; 457 | } 458 | 459 | function asn1BitString(content: Uint8Array): Uint8Array { 460 | const contentLength = content.length + 1; // +1 for unused bits byte 461 | const lengthBytes = asn1Length(contentLength); 462 | 463 | const result = new Uint8Array(1 + lengthBytes.length + contentLength); 464 | result[0] = 0x03; // BIT STRING tag 465 | result.set(lengthBytes, 1); 466 | result[1 + lengthBytes.length] = 0x00; // 0 unused bits 467 | result.set(content, 2 + lengthBytes.length); 468 | 469 | return result; 470 | } 471 | 472 | function uint8ArrayToBase64(bytes: Uint8Array): string { 473 | // Use Buffer from the buffer module for base64 encoding 474 | return Buffer.from(bytes).toString("base64"); 475 | } 476 | 477 | /** 478 | * Parse a base64-encoded string to Uint8Array 479 | */ 480 | export function base64ToUint8Array(base64: string): Uint8Array { 481 | // Use Buffer from the buffer module for base64 decoding 482 | const buffer = Buffer.from(base64, "base64"); 483 | return new Uint8Array(buffer); 484 | } 485 | -------------------------------------------------------------------------------- /packages/backend/src/stores/interactsh.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | 4 | import type { SDK } from "caido:plugin"; 5 | import type { 6 | GenerateUrlResult, 7 | Interaction, 8 | InteractshStartOptions, 9 | } from "shared"; 10 | 11 | import { 12 | emitDataChanged, 13 | emitFilterChanged, 14 | emitFilterEnabledChanged, 15 | emitUrlGenerated, 16 | } from "../index"; 17 | import { 18 | createInteractshClient, 19 | type InteractshClient, 20 | } from "../services/interactsh"; 21 | 22 | export interface ActiveUrl { 23 | url: string; 24 | uniqueId: string; 25 | createdAt: string; 26 | isActive: boolean; 27 | serverUrl: string; 28 | tag?: string; 29 | } 30 | 31 | interface ServerClient { 32 | client: InteractshClient; 33 | serverUrl: string; 34 | } 35 | 36 | interface PersistedData { 37 | interactions: Interaction[]; 38 | activeUrls: ActiveUrl[]; 39 | interactionCounter: number; 40 | filter: string; 41 | filterEnabled: boolean; 42 | } 43 | 44 | export class InteractshStore { 45 | private static _instance?: InteractshStore; 46 | private clients: Map = new Map(); 47 | private interactions: Interaction[] = []; 48 | private activeUrls: ActiveUrl[] = []; 49 | private filter = ""; 50 | private filterEnabled = true; 51 | private sdk: SDK; 52 | private isStarted = false; 53 | private interactionCounter = 0; 54 | private currentOptions: InteractshStartOptions | undefined; 55 | private readonly dataPath: string; 56 | 57 | private constructor(sdk: SDK) { 58 | this.sdk = sdk; 59 | this.dataPath = path.join(this.sdk.meta.path(), "data.json"); 60 | this.loadPersistedData(); 61 | } 62 | 63 | static get(sdk: SDK): InteractshStore { 64 | if (!InteractshStore._instance) { 65 | InteractshStore._instance = new InteractshStore(sdk); 66 | } 67 | return InteractshStore._instance; 68 | } 69 | 70 | private loadPersistedData(): void { 71 | try { 72 | const fileData = fs.readFileSync(this.dataPath, { encoding: "utf-8" }); 73 | const parsed: PersistedData = JSON.parse(fileData); 74 | this.interactions = parsed.interactions || []; 75 | this.activeUrls = parsed.activeUrls || []; 76 | this.interactionCounter = parsed.interactionCounter || 0; 77 | this.filter = parsed.filter || ""; 78 | this.filterEnabled = parsed.filterEnabled !== false; // Default to true 79 | this.sdk.console.log( 80 | `Loaded persisted data: ${this.interactions.length} interactions, ${this.activeUrls.length} URLs`, 81 | ); 82 | } catch { 83 | // File doesn't exist yet or is invalid - start with empty data 84 | this.interactions = []; 85 | this.activeUrls = []; 86 | this.interactionCounter = 0; 87 | this.filter = ""; 88 | this.filterEnabled = true; 89 | } 90 | } 91 | 92 | private savePersistedData(notify = true): void { 93 | try { 94 | const persistData: PersistedData = { 95 | interactions: this.interactions, 96 | activeUrls: this.activeUrls, 97 | interactionCounter: this.interactionCounter, 98 | filter: this.filter, 99 | filterEnabled: this.filterEnabled, 100 | }; 101 | fs.writeFileSync(this.dataPath, JSON.stringify(persistData, null, 2)); 102 | if (notify) { 103 | emitDataChanged(); 104 | } 105 | } catch (error) { 106 | this.sdk.console.error(`Failed to save persisted data: ${error}`); 107 | } 108 | } 109 | 110 | private parseInteraction( 111 | json: Record, 112 | tag?: string, 113 | serverUrl?: string, 114 | ): Interaction { 115 | const toString = (value: unknown): string => { 116 | if (typeof value === "string") { 117 | return value; 118 | } 119 | if (value === undefined || value === null) { 120 | return ""; 121 | } 122 | if (typeof value === "number" || typeof value === "boolean") { 123 | return String(value); 124 | } 125 | // For objects/arrays, use JSON.stringify to get meaningful output 126 | if (typeof value === "object") { 127 | try { 128 | return JSON.stringify(value); 129 | } catch { 130 | return ""; 131 | } 132 | } 133 | return ""; 134 | }; 135 | 136 | // Generate a truly unique ID for each interaction 137 | this.interactionCounter++; 138 | const uniqueId = `int_${Date.now()}_${this.interactionCounter}`; 139 | 140 | return { 141 | protocol: toString(json.protocol ?? "unknown"), 142 | uniqueId, 143 | fullId: toString(json["full-id"] ?? ""), 144 | qType: toString(json["q-type"] ?? ""), 145 | rawRequest: toString(json["raw-request"] ?? ""), 146 | rawResponse: toString(json["raw-response"] ?? ""), 147 | remoteAddress: toString(json["remote-address"] ?? ""), 148 | timestamp: toString(json.timestamp ?? new Date().toISOString()), 149 | tag, 150 | serverUrl, 151 | }; 152 | } 153 | 154 | start(options: InteractshStartOptions): boolean { 155 | if (this.isStarted) { 156 | this.sdk.console.log("Interactsh store already started"); 157 | return true; 158 | } 159 | 160 | this.currentOptions = options; 161 | this.interactions = []; 162 | this.isStarted = true; 163 | this.sdk.console.log("Interactsh store initialized"); 164 | return true; 165 | } 166 | 167 | private async getOrCreateClient( 168 | serverUrl: string, 169 | ): Promise { 170 | const existing = this.clients.get(serverUrl); 171 | if (existing) { 172 | return existing.client; 173 | } 174 | 175 | if (!this.currentOptions) { 176 | throw new Error("Store not initialized"); 177 | } 178 | 179 | const client = createInteractshClient(this.sdk); 180 | await client.start( 181 | { 182 | serverURL: serverUrl, 183 | token: this.currentOptions.token, 184 | keepAliveInterval: this.currentOptions.pollingInterval, 185 | correlationIdLength: this.currentOptions.correlationIdLength, 186 | correlationIdNonceLength: this.currentOptions.correlationIdNonceLength, 187 | }, 188 | (interaction: Record) => { 189 | // Check if this interaction's URL is tracked and active 190 | const rawFullId = interaction["full-id"]; 191 | const fullId = typeof rawFullId === "string" ? rawFullId : ""; 192 | const matchingUrl = this.activeUrls.find( 193 | (u) => fullId.startsWith(u.uniqueId) || u.uniqueId === fullId, 194 | ); 195 | 196 | // Only add interaction if URL is found AND is active 197 | if (matchingUrl && matchingUrl.isActive) { 198 | // Pass the tag and serverUrl from the matching URL to the interaction 199 | const parsed = this.parseInteraction( 200 | interaction, 201 | matchingUrl.tag, 202 | matchingUrl.serverUrl, 203 | ); 204 | this.interactions.push(parsed); 205 | // Don't emit event for new interactions - polling handles this 206 | this.savePersistedData(false); 207 | this.sdk.console.log( 208 | `New interaction received: ${parsed.protocol}${parsed.tag ? ` [${parsed.tag}]` : ""}`, 209 | ); 210 | } else if (matchingUrl && !matchingUrl.isActive) { 211 | this.sdk.console.log(`Interaction ignored (URL disabled): ${fullId}`); 212 | } else { 213 | this.sdk.console.log( 214 | `Interaction ignored (URL not tracked): ${fullId}`, 215 | ); 216 | } 217 | }, 218 | ); 219 | 220 | this.clients.set(serverUrl, { client, serverUrl }); 221 | this.sdk.console.log(`Created client for server: ${serverUrl}`); 222 | return client; 223 | } 224 | 225 | async stop(): Promise { 226 | if (!this.isStarted) { 227 | this.sdk.console.log("Interactsh store not started"); 228 | return true; 229 | } 230 | 231 | try { 232 | // Stop all clients 233 | for (const [serverUrl, { client }] of this.clients) { 234 | try { 235 | await client.stop(); 236 | this.sdk.console.log(`Stopped client for server: ${serverUrl}`); 237 | } catch (error) { 238 | this.sdk.console.error( 239 | `Failed to stop client for ${serverUrl}: ${error}`, 240 | ); 241 | } 242 | } 243 | this.clients.clear(); 244 | this.isStarted = false; 245 | this.currentOptions = undefined; 246 | this.sdk.console.log("All Interactsh clients stopped successfully"); 247 | return true; 248 | } catch (error) { 249 | this.sdk.console.error(`Failed to stop Interactsh clients: ${error}`); 250 | throw error; 251 | } 252 | } 253 | 254 | async generateUrl( 255 | serverUrl: string, 256 | tag?: string, 257 | ): Promise { 258 | if (!this.isStarted) { 259 | throw new Error("Interactsh store not started"); 260 | } 261 | 262 | const client = await this.getOrCreateClient(serverUrl); 263 | const result = client.generateUrl(); 264 | 265 | // Track this URL as active 266 | this.activeUrls.push({ 267 | url: result.url, 268 | uniqueId: result.uniqueId, 269 | createdAt: new Date().toISOString(), 270 | isActive: true, 271 | serverUrl, 272 | tag, 273 | }); 274 | 275 | this.savePersistedData(false); 276 | // Emit URL generated event to sync across tabs 277 | emitUrlGenerated(result.url); 278 | return result; 279 | } 280 | 281 | getInteractions(): Interaction[] { 282 | return [...this.interactions]; 283 | } 284 | 285 | getNewInteractions(lastIndex: number): Interaction[] { 286 | return this.interactions.slice(lastIndex); 287 | } 288 | 289 | async poll(notifyOthers = false): Promise { 290 | if (!this.isStarted) { 291 | throw new Error("Interactsh store not started"); 292 | } 293 | 294 | const countBefore = this.interactions.length; 295 | 296 | // Poll all clients 297 | for (const [serverUrl, { client }] of this.clients) { 298 | try { 299 | await client.poll(); 300 | } catch (error) { 301 | this.sdk.console.error(`Failed to poll ${serverUrl}: ${error}`); 302 | } 303 | } 304 | 305 | // If notifyOthers is true and we got new interactions, emit event 306 | if (notifyOthers && this.interactions.length > countBefore) { 307 | emitDataChanged(); 308 | } 309 | } 310 | 311 | clearInteractions(): void { 312 | this.interactions = []; 313 | this.savePersistedData(); 314 | } 315 | 316 | deleteInteraction(uniqueId: string): boolean { 317 | const index = this.interactions.findIndex((i) => i.uniqueId === uniqueId); 318 | if (index !== -1) { 319 | this.interactions.splice(index, 1); 320 | this.savePersistedData(); 321 | return true; 322 | } 323 | return false; 324 | } 325 | 326 | deleteInteractions(uniqueIds: string[]): number { 327 | const idsSet = new Set(uniqueIds); 328 | const initialLength = this.interactions.length; 329 | this.interactions = this.interactions.filter( 330 | (i) => !idsSet.has(i.uniqueId), 331 | ); 332 | const deletedCount = initialLength - this.interactions.length; 333 | if (deletedCount > 0) { 334 | this.savePersistedData(); 335 | } 336 | return deletedCount; 337 | } 338 | 339 | getStatus(): { isStarted: boolean; interactionCount: number } { 340 | return { 341 | isStarted: this.isStarted, 342 | interactionCount: this.interactions.length, 343 | }; 344 | } 345 | 346 | // URL Management methods 347 | getActiveUrls(): ActiveUrl[] { 348 | return [...this.activeUrls]; 349 | } 350 | 351 | setUrlActive(uniqueId: string, isActive: boolean): boolean { 352 | const url = this.activeUrls.find((u) => u.uniqueId === uniqueId); 353 | if (url) { 354 | url.isActive = isActive; 355 | this.savePersistedData(); 356 | this.sdk.console.log( 357 | `URL ${uniqueId} set to ${isActive ? "active" : "inactive"}`, 358 | ); 359 | return true; 360 | } 361 | return false; 362 | } 363 | 364 | removeUrl(uniqueId: string): boolean { 365 | const index = this.activeUrls.findIndex((u) => u.uniqueId === uniqueId); 366 | if (index !== -1) { 367 | this.activeUrls.splice(index, 1); 368 | this.savePersistedData(); 369 | this.sdk.console.log(`URL ${uniqueId} removed from tracking`); 370 | return true; 371 | } 372 | return false; 373 | } 374 | 375 | clearUrls(): void { 376 | this.activeUrls = []; 377 | this.savePersistedData(); 378 | this.sdk.console.log("All tracked URLs cleared"); 379 | } 380 | 381 | // Clear all persisted data (interactions, URLs, counter) 382 | clearAllData(): void { 383 | this.interactions = []; 384 | this.activeUrls = []; 385 | this.interactionCounter = 0; 386 | this.savePersistedData(); 387 | this.sdk.console.log("All data cleared"); 388 | } 389 | 390 | // Pre-initialize clients for multiple servers (for random mode) 391 | async initializeClients(serverUrls: string[]): Promise { 392 | if (!this.isStarted) { 393 | throw new Error("Interactsh store not started"); 394 | } 395 | 396 | let initialized = 0; 397 | const promises = serverUrls.map(async (serverUrl) => { 398 | try { 399 | await this.getOrCreateClient(serverUrl); 400 | initialized++; 401 | } catch (error) { 402 | this.sdk.console.error( 403 | `Failed to initialize client for ${serverUrl}: ${error}`, 404 | ); 405 | } 406 | }); 407 | 408 | await Promise.all(promises); 409 | this.sdk.console.log( 410 | `Initialized ${initialized}/${serverUrls.length} clients`, 411 | ); 412 | return initialized; 413 | } 414 | 415 | // Get count of initialized clients 416 | getClientCount(): number { 417 | return this.clients.size; 418 | } 419 | 420 | // Filter management 421 | setFilter(filter: string): void { 422 | if (this.filter !== filter) { 423 | this.filter = filter; 424 | this.savePersistedData(false); 425 | emitFilterChanged(filter); 426 | } 427 | } 428 | 429 | getFilter(): string { 430 | return this.filter; 431 | } 432 | 433 | // Filter enabled management 434 | setFilterEnabled(enabled: boolean): void { 435 | if (this.filterEnabled !== enabled) { 436 | this.filterEnabled = enabled; 437 | this.savePersistedData(false); 438 | emitFilterEnabledChanged(enabled); 439 | } 440 | } 441 | 442 | getFilterEnabled(): boolean { 443 | return this.filterEnabled; 444 | } 445 | 446 | // Update tag for an interaction 447 | setInteractionTag(uniqueId: string, tag: string | undefined): boolean { 448 | const interaction = this.interactions.find((i) => i.uniqueId === uniqueId); 449 | if (interaction) { 450 | interaction.tag = tag; 451 | this.savePersistedData(); 452 | return true; 453 | } 454 | return false; 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /packages/frontend/src/components/PayloadTable.vue: -------------------------------------------------------------------------------- 1 | 296 | 297 | 454 | 455 | 494 | -------------------------------------------------------------------------------- /packages/frontend/src/components/FilterBar.vue: -------------------------------------------------------------------------------- 1 | 558 | 559 | 633 | 634 | 689 | -------------------------------------------------------------------------------- /packages/frontend/src/stores/interactionStore.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { computed, ref } from "vue"; 3 | 4 | import { sidebarItem } from "@/index"; 5 | import { useSDK } from "@/plugins/sdk"; 6 | import { SERVER_PRESETS, useSettingsStore } from "@/stores/settingsStore"; 7 | import { useUIStore } from "@/stores/uiStore"; 8 | import type { Interaction } from "@/types"; 9 | import { tryCatch } from "@/utils/try-catch"; 10 | 11 | export const useInteractionStore = defineStore("interaction", () => { 12 | const uiStore = useUIStore(); 13 | const sdk = useSDK(); 14 | 15 | const data = ref([]); 16 | const isStarted = ref(false); 17 | const lastInteractionIndex = ref(0); 18 | const filterQuery = ref(""); 19 | const filterEnabled = ref(true); 20 | const selectedRows = ref([]); 21 | const rowColors = ref>({}); 22 | let pollingIntervalId: ReturnType | undefined; 23 | 24 | // Flag to skip next data change event (when we made the change ourselves) 25 | let skipNextDataChangeEvent = false; 26 | // Flag to skip next filter change event (when we made the change ourselves) 27 | let skipNextFilterChangeEvent = false; 28 | // Flag to skip next filter enabled change event (when we made the change ourselves) 29 | let skipNextFilterEnabledChangeEvent = false; 30 | 31 | // Filter condition type: field.operator:"value" 32 | type FilterCondition = { 33 | field: string; 34 | operator: string; 35 | value: string; 36 | }; 37 | type FilterGroup = { conditions: FilterCondition[]; operator: "AND" | "OR" }; 38 | 39 | // Parse HTTPQL-style filter: field.operator:"value" or field.operator:value 40 | function parseCondition(token: string): FilterCondition | undefined { 41 | // Match: field.operator:"value" or field.operator:value 42 | const match = token.match(/^(\w+)\.(\w+):["']?(.+?)["']?$/); 43 | if (match && match[1] && match[2] && match[3]) { 44 | return { 45 | field: match[1].toLowerCase(), 46 | operator: match[2].toLowerCase(), 47 | value: match[3], 48 | }; 49 | } 50 | 51 | // Legacy format: field:value (treat as cont) 52 | const colonIndex = token.indexOf(":"); 53 | if (colonIndex > 0) { 54 | const field = token.substring(0, colonIndex).toLowerCase(); 55 | let value = token.substring(colonIndex + 1); 56 | value = value.replace(/^["']|["']$/g, ""); // Remove quotes 57 | if (value) { 58 | return { field, operator: "cont", value }; 59 | } 60 | } 61 | 62 | return undefined; 63 | } 64 | 65 | // Parse HTTPQL-style filter query with AND/OR support 66 | function parseFilter(query: string): FilterGroup[] { 67 | const groups: FilterGroup[] = []; 68 | if (!query.trim()) return groups; 69 | 70 | // Split by OR first (lower precedence) 71 | const orParts = query.trim().split(/\s+OR\s+/i); 72 | 73 | for (const orPart of orParts) { 74 | // Split by AND or space (implicit AND) 75 | const andParts = orPart.trim().split(/\s+(?:AND\s+)?/i); 76 | const conditions: FilterCondition[] = []; 77 | 78 | for (const part of andParts) { 79 | const condition = parseCondition(part); 80 | if (condition) { 81 | conditions.push(condition); 82 | } 83 | } 84 | 85 | if (conditions.length > 0) { 86 | groups.push({ conditions, operator: "AND" }); 87 | } 88 | } 89 | 90 | return groups; 91 | } 92 | 93 | // Get field value from item 94 | function getFieldValue( 95 | item: Interaction & { req: number; dateTime: string; payloadUrl?: string }, 96 | field: string, 97 | ): string { 98 | switch (field) { 99 | case "protocol": 100 | case "type": 101 | return item.protocol.toLowerCase(); 102 | case "ip": 103 | case "source": 104 | return item.remoteAddress.toLowerCase(); 105 | case "path": 106 | return (item.httpPath || "").toLowerCase(); 107 | case "payload": 108 | case "id": 109 | return (item.payloadUrl || item.fullId).toLowerCase(); 110 | case "tag": 111 | return (item.tag || "").toLowerCase(); 112 | default: 113 | return ""; 114 | } 115 | } 116 | 117 | // Apply operator to check if value matches 118 | function applyOperator( 119 | fieldValue: string, 120 | operator: string, 121 | filterValue: string, 122 | ): boolean { 123 | const lowerFilterValue = filterValue.toLowerCase(); 124 | 125 | switch (operator) { 126 | case "eq": 127 | return fieldValue === lowerFilterValue; 128 | case "ne": 129 | return fieldValue !== lowerFilterValue; 130 | case "cont": 131 | return fieldValue.includes(lowerFilterValue); 132 | case "ncont": 133 | return !fieldValue.includes(lowerFilterValue); 134 | case "like": { 135 | // Convert SQL LIKE pattern to regex: % -> .*, _ -> . 136 | const likePattern = lowerFilterValue 137 | .replace(/%/g, ".*") 138 | .replace(/_/g, "."); 139 | return new RegExp(`^${likePattern}$`, "i").test(fieldValue); 140 | } 141 | case "nlike": { 142 | const nlikePattern = lowerFilterValue 143 | .replace(/%/g, ".*") 144 | .replace(/_/g, "."); 145 | return !new RegExp(`^${nlikePattern}$`, "i").test(fieldValue); 146 | } 147 | case "regex": 148 | try { 149 | return new RegExp(filterValue, "i").test(fieldValue); 150 | } catch { 151 | return false; 152 | } 153 | case "nregex": 154 | try { 155 | return !new RegExp(filterValue, "i").test(fieldValue); 156 | } catch { 157 | return true; 158 | } 159 | default: 160 | // Default to contains 161 | return fieldValue.includes(lowerFilterValue); 162 | } 163 | } 164 | 165 | // Check if a single condition matches 166 | function matchesCondition( 167 | item: Interaction & { req: number; dateTime: string }, 168 | condition: FilterCondition, 169 | ): boolean { 170 | const fieldValue = getFieldValue(item, condition.field); 171 | return applyOperator(fieldValue, condition.operator, condition.value); 172 | } 173 | 174 | // Check if interaction matches filter groups (OR between groups, AND within group) 175 | function matchesFilter( 176 | item: Interaction & { req: number; dateTime: string }, 177 | groups: FilterGroup[], 178 | ): boolean { 179 | if (groups.length === 0) return true; 180 | 181 | // OR between groups: at least one group must match 182 | return groups.some((group) => { 183 | // AND within group: all conditions must match 184 | return group.conditions.every((condition) => 185 | matchesCondition(item, condition), 186 | ); 187 | }); 188 | } 189 | 190 | const tableData = computed(() => { 191 | return data.value.map((item: Interaction, index: number) => { 192 | const date = new Date(item.timestamp); 193 | // Build full payload URL: fullId.serverDomain 194 | let payloadUrl = item.fullId; 195 | if (item.serverUrl) { 196 | try { 197 | const serverDomain = new URL(item.serverUrl).hostname; 198 | payloadUrl = `${item.fullId}.${serverDomain}`; 199 | } catch { 200 | // Keep original fullId if URL parsing fails 201 | } 202 | } 203 | return { 204 | ...item, 205 | req: index + 1, 206 | dateTime: date.toISOString(), 207 | localDateTime: date.toLocaleString(), 208 | protocol: item.protocol.toUpperCase(), 209 | payloadUrl, 210 | }; 211 | }); 212 | }); 213 | 214 | const filteredTableData = computed(() => { 215 | // If filter is disabled, return all data 216 | if (!filterEnabled.value) return tableData.value; 217 | const groups = parseFilter(filterQuery.value); 218 | if (groups.length === 0) return tableData.value; 219 | return tableData.value.filter((item) => matchesFilter(item, groups)); 220 | }); 221 | 222 | // Toggle filter enabled/disabled 223 | function toggleFilter() { 224 | filterEnabled.value = !filterEnabled.value; 225 | // Skip next event since we're making the change 226 | skipNextFilterEnabledChangeEvent = true; 227 | sdk.backend.setFilterEnabled(filterEnabled.value); 228 | } 229 | 230 | function processInteraction( 231 | interaction: Omit, 232 | ): Interaction { 233 | const result: Interaction = { 234 | ...interaction, 235 | httpPath: "", 236 | }; 237 | 238 | // Extract HTTP path for HTTP requests 239 | if (result.protocol.toLowerCase() === "http") { 240 | const firstLine = result.rawRequest.split("\r\n")[0] || ""; 241 | const parts = firstLine.split(" "); 242 | result.httpPath = 243 | parts.length >= 2 && parts[1]?.startsWith("/") ? parts[1] : ""; 244 | } 245 | 246 | // Normalize line endings for DNS requests 247 | if (result.protocol.toLowerCase() === "dns") { 248 | result.rawRequest = result.rawRequest.split("\n").join("\r\n"); 249 | result.rawResponse = result.rawResponse.split("\n").join("\r\n"); 250 | } 251 | 252 | return result; 253 | } 254 | 255 | function addToData(response: Interaction) { 256 | if (window.location.hash !== "#/quickssrf") { 257 | uiStore.btnCount += 1; 258 | sidebarItem.setCount(uiStore.btnCount); 259 | } 260 | 261 | data.value.push(response); 262 | } 263 | 264 | async function fetchNewInteractions() { 265 | const { data: newInteractions, error } = await tryCatch( 266 | sdk.backend.getNewInteractions(lastInteractionIndex.value), 267 | ); 268 | 269 | if (error) { 270 | console.error("Failed to fetch new interactions:", error); 271 | return; 272 | } 273 | 274 | if (newInteractions && newInteractions.length > 0) { 275 | for (const interaction of newInteractions) { 276 | const processed = processInteraction(interaction); 277 | addToData(processed); 278 | } 279 | lastInteractionIndex.value += newInteractions.length; 280 | } 281 | } 282 | 283 | function startPolling(intervalMs: number) { 284 | if (pollingIntervalId) { 285 | clearInterval(pollingIntervalId); 286 | } 287 | 288 | pollingIntervalId = setInterval(() => { 289 | fetchNewInteractions(); 290 | }, intervalMs); 291 | } 292 | 293 | function stopPolling() { 294 | if (pollingIntervalId) { 295 | clearInterval(pollingIntervalId); 296 | pollingIntervalId = undefined; 297 | } 298 | } 299 | 300 | async function initializeService() { 301 | if (isStarted.value) return true; 302 | 303 | const settings = useSettingsStore(); 304 | 305 | // Get effective server URL (handles random mode) 306 | const effectiveServerUrl = settings.getEffectiveServerUrl(); 307 | 308 | const { error } = await tryCatch( 309 | sdk.backend.startInteractsh({ 310 | serverURL: effectiveServerUrl, 311 | token: settings.token, 312 | pollingInterval: settings.pollingInterval, 313 | correlationIdLength: settings.correlationIdLength, 314 | correlationIdNonceLength: settings.correlationIdNonceLength, 315 | }), 316 | ); 317 | 318 | if (error) { 319 | console.error("Failed to initialize service:", error); 320 | return false; 321 | } 322 | 323 | isStarted.value = true; 324 | startPolling(settings.pollingInterval); 325 | 326 | // If random mode is enabled, pre-initialize all server clients in background 327 | if (settings.serverMode === "random") { 328 | const allServerUrls = SERVER_PRESETS.filter( 329 | (p) => p.value !== "random" && p.value !== "custom", 330 | ).map((p) => p.value); 331 | sdk.backend 332 | .initializeClients(allServerUrls) 333 | .then((count) => { 334 | console.log( 335 | `Pre-initialized ${count} server clients for random mode`, 336 | ); 337 | }) 338 | .catch((err) => { 339 | console.error("Failed to pre-initialize clients:", err); 340 | }); 341 | } 342 | 343 | return true; 344 | } 345 | 346 | async function resetClientService() { 347 | stopPolling(); 348 | 349 | if (isStarted.value) { 350 | const { error } = await tryCatch(sdk.backend.stopInteractsh()); 351 | if (error) { 352 | console.error("Error stopping client service:", error); 353 | } 354 | isStarted.value = false; 355 | } 356 | 357 | lastInteractionIndex.value = 0; 358 | } 359 | 360 | async function generateUrl(tag?: string) { 361 | const initialized = await initializeService(); 362 | if (!initialized) { 363 | return undefined; 364 | } 365 | 366 | const settings = useSettingsStore(); 367 | // Get effective server URL (handles random mode - picks a new random server each time) 368 | const serverUrl = settings.getEffectiveServerUrl(); 369 | 370 | // Only pass tag if it's defined (Caido API doesn't handle undefined well) 371 | const { data: result, error } = await tryCatch( 372 | tag 373 | ? sdk.backend.generateInteractshUrl(serverUrl, tag) 374 | : sdk.backend.generateInteractshUrl(serverUrl), 375 | ); 376 | 377 | if (error) { 378 | console.error("Failed to generate URL:", error); 379 | return undefined; 380 | } 381 | 382 | return result?.url || undefined; 383 | } 384 | 385 | async function manualPoll() { 386 | if (!isStarted.value) { 387 | throw new Error("Client service not initialized"); 388 | } 389 | 390 | // Skip next event since we're making the change ourselves 391 | skipNextDataChangeEvent = true; 392 | 393 | // Force backend to poll the Interactsh server (notify other tabs) 394 | await sdk.backend.pollInteractsh(true); 395 | // Then fetch the new interactions 396 | await fetchNewInteractions(); 397 | } 398 | 399 | function clearData(resetService = false) { 400 | // Skip next event since we're making the change 401 | skipNextDataChangeEvent = true; 402 | 403 | data.value = []; 404 | lastInteractionIndex.value = 0; 405 | selectedRows.value = []; 406 | rowColors.value = {}; 407 | 408 | uiStore.setBtnCount(0); 409 | sidebarItem.setCount(uiStore.btnCount); 410 | 411 | // Clear all data on backend (interactions + URLs + persisted data) 412 | sdk.backend.clearAllData(); 413 | 414 | if (resetService) { 415 | resetClientService(); 416 | } 417 | 418 | return true; 419 | } 420 | 421 | // Delete a single interaction by uniqueId 422 | async function deleteInteraction(uniqueId: string) { 423 | // Skip next event since we're making the change 424 | skipNextDataChangeEvent = true; 425 | 426 | // Delete from backend first 427 | await sdk.backend.deleteInteraction(uniqueId); 428 | 429 | // Then update local state 430 | const index = data.value.findIndex((item) => item.uniqueId === uniqueId); 431 | if (index !== -1) { 432 | data.value.splice(index, 1); 433 | } 434 | // Also remove from selection if present 435 | selectedRows.value = selectedRows.value.filter( 436 | (item) => item.uniqueId !== uniqueId, 437 | ); 438 | // Also remove color if present 439 | delete rowColors.value[uniqueId]; 440 | } 441 | 442 | // Delete all selected interactions 443 | async function deleteSelected() { 444 | // Skip next event since we're making the change 445 | skipNextDataChangeEvent = true; 446 | 447 | const selectedIds = selectedRows.value.map((item) => item.uniqueId); 448 | 449 | // Delete from backend first 450 | await sdk.backend.deleteInteractions(selectedIds); 451 | 452 | // Then update local state 453 | const selectedIdsSet = new Set(selectedIds); 454 | data.value = data.value.filter( 455 | (item) => !selectedIdsSet.has(item.uniqueId), 456 | ); 457 | // Also remove colors 458 | for (const id of selectedIds) { 459 | delete rowColors.value[id]; 460 | } 461 | selectedRows.value = []; 462 | } 463 | 464 | // Clear filter 465 | function clearFilter() { 466 | filterQuery.value = ""; 467 | } 468 | 469 | // Set row color 470 | function setRowColor(fullId: string, color: string | undefined) { 471 | if (color === undefined) { 472 | delete rowColors.value[fullId]; 473 | } else { 474 | rowColors.value[fullId] = color; 475 | } 476 | } 477 | 478 | // Get row color 479 | function getRowColor(fullId: string): string | undefined { 480 | return rowColors.value[fullId]; 481 | } 482 | 483 | // Load persisted data from backend and restore service state 484 | async function loadPersistedData() { 485 | const uiStore = useUIStore(); 486 | const settings = useSettingsStore(); 487 | 488 | // Check if backend service is already started 489 | const { data: status, error: statusError } = await tryCatch( 490 | sdk.backend.getInteractshStatus(), 491 | ); 492 | 493 | if (!statusError && status?.isStarted) { 494 | // Backend is already running, sync frontend state 495 | isStarted.value = true; 496 | startPolling(settings.pollingInterval); 497 | console.log("Backend service already running, synced frontend state"); 498 | } 499 | 500 | // Load interactions 501 | const { data: interactions, error } = await tryCatch( 502 | sdk.backend.getInteractions(), 503 | ); 504 | 505 | if (!error && interactions && interactions.length > 0) { 506 | // Process interactions to add httpPath 507 | for (const interaction of interactions) { 508 | const processed = processInteraction(interaction); 509 | data.value.push(processed); 510 | } 511 | lastInteractionIndex.value = interactions.length; 512 | console.log(`Loaded ${interactions.length} interactions from backend`); 513 | } 514 | 515 | // Load last generated URL from active URLs 516 | const { data: activeUrls, error: urlsError } = await tryCatch( 517 | sdk.backend.getActiveUrls(), 518 | ); 519 | 520 | if (!urlsError && activeUrls && activeUrls.length > 0) { 521 | // Get the most recent URL (last in array) 522 | const lastUrl = activeUrls[activeUrls.length - 1]; 523 | if (lastUrl) { 524 | uiStore.setGeneratedUrl(lastUrl.url); 525 | console.log(`Restored last generated URL: ${lastUrl.url}`); 526 | } 527 | } 528 | } 529 | 530 | // Reload all data from backend (called when data changes externally) 531 | async function reloadData() { 532 | const { data: interactions, error } = await tryCatch( 533 | sdk.backend.getInteractions(), 534 | ); 535 | 536 | if (error) { 537 | console.error("Failed to reload data:", error); 538 | return; 539 | } 540 | 541 | // Clear current data and reload 542 | data.value = []; 543 | selectedRows.value = []; 544 | 545 | if (interactions && interactions.length > 0) { 546 | for (const interaction of interactions) { 547 | const processed = processInteraction(interaction); 548 | data.value.push(processed); 549 | } 550 | lastInteractionIndex.value = interactions.length; 551 | console.log(`Reloaded ${interactions.length} interactions from backend`); 552 | } else { 553 | lastInteractionIndex.value = 0; 554 | } 555 | } 556 | 557 | // Subscribe to backend data change events 558 | function subscribeToDataChanges() { 559 | const subscription = sdk.backend.onEvent("onDataChanged", () => { 560 | // Skip if we made the change ourselves 561 | if (skipNextDataChangeEvent) { 562 | skipNextDataChangeEvent = false; 563 | console.log("Data changed event received, skipping (self-triggered)"); 564 | return; 565 | } 566 | console.log("Data changed event received, reloading..."); 567 | reloadData(); 568 | }); 569 | return subscription; 570 | } 571 | 572 | // Subscribe to URL generation events 573 | function subscribeToUrlGenerated() { 574 | const uiStore = useUIStore(); 575 | const subscription = sdk.backend.onEvent( 576 | "onUrlGenerated", 577 | (url: string) => { 578 | console.log("URL generated event received:", url); 579 | uiStore.setGeneratedUrl(url); 580 | }, 581 | ); 582 | return subscription; 583 | } 584 | 585 | // Subscribe to filter change events 586 | function subscribeToFilterChanged() { 587 | const subscription = sdk.backend.onEvent( 588 | "onFilterChanged", 589 | (filter: string) => { 590 | // Skip if we made the change ourselves 591 | if (skipNextFilterChangeEvent) { 592 | skipNextFilterChangeEvent = false; 593 | console.log( 594 | "Filter changed event received, skipping (self-triggered)", 595 | ); 596 | return; 597 | } 598 | console.log("Filter changed event received:", filter); 599 | filterQuery.value = filter; 600 | }, 601 | ); 602 | return subscription; 603 | } 604 | 605 | // Subscribe to filter enabled change events 606 | function subscribeToFilterEnabledChanged() { 607 | const subscription = sdk.backend.onEvent( 608 | "onFilterEnabledChanged", 609 | (enabled: boolean) => { 610 | // Skip if we made the change ourselves 611 | if (skipNextFilterEnabledChangeEvent) { 612 | skipNextFilterEnabledChangeEvent = false; 613 | console.log( 614 | "Filter enabled changed event received, skipping (self-triggered)", 615 | ); 616 | return; 617 | } 618 | console.log("Filter enabled changed event received:", enabled); 619 | filterEnabled.value = enabled; 620 | }, 621 | ); 622 | return subscription; 623 | } 624 | 625 | // Update filter and sync to backend 626 | function setFilterQuery(value: string) { 627 | if (filterQuery.value !== value) { 628 | filterQuery.value = value; 629 | // Skip next event since we're making the change 630 | skipNextFilterChangeEvent = true; 631 | sdk.backend.setFilter(value); 632 | } 633 | } 634 | 635 | // Load filter from backend 636 | async function loadFilter() { 637 | const { data: filter, error } = await tryCatch(sdk.backend.getFilter()); 638 | if (!error && filter !== undefined) { 639 | filterQuery.value = filter; 640 | console.log(`Loaded filter from backend: "${filter}"`); 641 | } 642 | 643 | const { data: enabled, error: enabledError } = await tryCatch( 644 | sdk.backend.getFilterEnabled(), 645 | ); 646 | if (!enabledError && enabled !== undefined) { 647 | filterEnabled.value = enabled; 648 | console.log(`Loaded filter enabled from backend: ${enabled}`); 649 | } 650 | } 651 | 652 | // Update tag for an interaction 653 | async function setInteractionTag(uniqueId: string, tag: string | undefined) { 654 | // Skip next event since we're making the change 655 | skipNextDataChangeEvent = true; 656 | 657 | // Update backend first 658 | await sdk.backend.setInteractionTag(uniqueId, tag); 659 | 660 | // Then update local state 661 | const interaction = data.value.find((item) => item.uniqueId === uniqueId); 662 | if (interaction) { 663 | interaction.tag = tag; 664 | } 665 | } 666 | 667 | // Generate multiple URLs 668 | async function generateMultipleUrls(count: number): Promise { 669 | const initialized = await initializeService(); 670 | if (!initialized) { 671 | return []; 672 | } 673 | 674 | const settings = useSettingsStore(); 675 | const urls: string[] = []; 676 | 677 | for (let i = 0; i < count; i++) { 678 | // Get effective server URL for each URL (random mode picks different servers) 679 | const serverUrl = settings.getEffectiveServerUrl(); 680 | const { data: result, error } = await tryCatch( 681 | sdk.backend.generateInteractshUrl(serverUrl), 682 | ); 683 | if (error) { 684 | console.error(`Failed to generate URL ${i + 1}:`, error); 685 | continue; 686 | } 687 | if (result?.url) { 688 | urls.push(result.url); 689 | } 690 | } 691 | 692 | return urls; 693 | } 694 | 695 | return { 696 | data, 697 | tableData, 698 | filteredTableData, 699 | filterQuery, 700 | filterEnabled, 701 | selectedRows, 702 | rowColors, 703 | toggleFilter, 704 | generateUrl, 705 | generateMultipleUrls, 706 | manualPoll, 707 | clearData, 708 | clearFilter, 709 | deleteInteraction, 710 | deleteSelected, 711 | resetClientService, 712 | setRowColor, 713 | getRowColor, 714 | loadPersistedData, 715 | reloadData, 716 | subscribeToDataChanges, 717 | subscribeToUrlGenerated, 718 | subscribeToFilterChanged, 719 | subscribeToFilterEnabledChanged, 720 | setFilterQuery, 721 | loadFilter, 722 | setInteractionTag, 723 | }; 724 | }); 725 | --------------------------------------------------------------------------------