Skip to content

Security ​

This page is Aerini's product security model: how the desktop app isolates itself, how credentials are encrypted, how outbound requests are checked against SSRF, what a .wasm plugin can and can't reach, and how to harden a server deployment. If you're deciding whether it's safe to expose a Webhook node to the internet or run aerini-server on a box other people can reach, this is the page for that decision. For the repository's vulnerability-reporting process instead, see the repo's root SECURITY.md, a different document with a similar name; the docs home explains the distinction.

Desktop security model ​

The desktop app is built on Tauri: a native shell with an embedded webview for the UI, backed by Rust for anything that touches your filesystem, network, or credential store.

The webview's own boundary ​

The UI you interact with runs inside that embedded webview, and it's locked down by a Content Security Policy that only allows scripts from the app itself, no inline or eval'd code. Its connect-src directive, the piece that governs any outbound fetch/XHR/WebSocket call the webview itself could make, permits the local IPC channel Aerini's own frontend and backend talk over, plus a set of AI-provider and OAuth domains (OpenAI, Anthropic, Google, Instagram, TikTok). Google Fonts is allowed separately, under style-src/font-src, for the one automatic connection described below; that's a different directive governing stylesheet and font loading, not connect-src's outbound-request allowlist. In the current build, none of Aerini's actual outbound calls to the AI/OAuth providers happen through the webview's own network stack either: AI Prompt, AI Agent, and the OAuth flows all run from the Rust engine, which connect-src doesn't govern and doesn't need to, since it isn't a webview page. The webview's native capabilities are cut down from Tauri's raw defaults, not left wide open: window and webview introspection and control (position, size, focus, visibility, and similar; no ability to open new windows), the event bus, app metadata, and the four file/message dialogs. None of that reaches the filesystem, a shell, or the network directly, which is the boundary that actually matters here. Everything else Aerini does, running a workflow, reading a file, decrypting a credential, loading a plugin, happens behind a fixed set of named commands the frontend calls into, not through an open API surface the webview holds itself.

Updates and the network ​

With default settings, Aerini makes exactly one automatic outbound connection with no workflow involved: loading the Inter font from Google Fonts over HTTPS, the same fact stated on the docs home and in the FAQ. Updating is a second, separate network action. By default it happens only when you click Check for Updates in Settings, and nothing checks for updates in the background. There is one opt-in exception: if you turn on Check on startup in Settings (it is off by default), Aerini makes a single request to GitHub each time it launches, and no other request on its own. That setting only looks for a newer release and tells you; installing always needs your click on Install and restart.

The check reads a small manifest file from the project's published GitHub releases. Installing downloads the package for your platform from the same release and verifies its minisign signature against a public key compiled into the app before anything is installed. A package that fails verification is discarded. The manifest itself isn't signed, so the app also requires each signature to carry the version number of the release it belongs to. That stops a tampered manifest from pairing a high version number with an older, genuinely signed build, which would otherwise be a forced downgrade. Only published releases are offered; drafts and prereleases never are.

The check and the install run in the Rust backend. The webview holds no permission to call the updater and never supplies a URL or a version, so it can only ask to check, install what the last check found, or cancel. The startup check is the same check, run by the backend at most once per launch and only when the stored Check on startup setting is on, which the backend reads itself rather than trusting the webview; a failed startup check says nothing. The release-notes and manual-download links shown in the UI are built by the app from a fixed repository address, not taken from the manifest. If a workflow is running, Aerini asks before it stops it to install. When an in-app install isn't possible, or anything fails, the app falls back to a link to the release page and you install by hand, as covered in Updating.

Links in the app, such as Support on Patreon, View on GitHub, the release-notes and download links under Updates, and the setup links for webhook tunnels and social uploads, open in your default browser rather than inside Aerini. The webview can ask the operating system to open only a short, fixed list of HTTPS addresses: this project's GitHub and Patreon pages, the Google Cloud, Meta and TikTok developer consoles, the cloudflared and ngrok setup pages, and YouTube watch links. Any other link, including one in an AI reply or a workflow's output, is blocked and Aerini shows a short notice instead of navigating away from the app. The list is enforced in the Rust backend, so page content can't widen it, and the webview has no permission to open files or folders.

Code (JS) on the desktop app ​

By default, on the desktop app, a Code (JS) node runs with a full, unrestricted Node.js runtime: it can import fs, child_process, net, or anything else in the standard library, and nothing at the engine level stops it. The only guard is the confirmation dialog and canvas badge shared by every dangerous node, covered in the Nodes Reference and not repeated here. That's a deliberate tradeoff for a single-user local app: you already control every workflow you build or import, so an engine-level sandbox would only add friction. That tradeoff changes once code you didn't write starts running unattended on a machine other people can reach, which is exactly the case the sandboxing described below exists for.

Credential encryption ​

Covered in full in Credentials; this is the one-paragraph summary. Every saved credential is encrypted with AES-256-GCM before it touches disk, and the encryption key itself lives in your OS keyring (Keychain, Credential Manager, or Secret Service), not in a plain file next to your workflows. On Linux and macOS, if the key has to live in a file instead (no keyring available, or aerini-server without --keychain), Aerini creates that file, the credential database, and any folder it has to create for them, readable by your user only from the moment they exist, and writes the key through a temporary file that is flushed to disk before it takes its final name, so a crash cannot leave an empty key file behind. On Windows the key file is restricted to your user before the key is written to it. Each data folder has its own keychain entry. Each credential is encrypted together with its ID and type, so a stored secret copied onto another credential does not decrypt, and Aerini checks the key against a stored check value before it reads any credential. Decrypted secrets, the encryption key and the cipher are overwritten when Aerini drops them; copies in a node's input, HTTP buffers, the process environment and the window cannot be wiped (see Known issues). Aerini has no account system and no server to send that key or your secrets to. The one gap worth knowing up front: only fields literally named api_key or password get the automatic "Use Saved Credential" picker, so a handful of fields on a few nodes are typed in directly and saved into the workflow file unencrypted. Credentials §Using a credential in a node has the exact node-by-node breakdown, and Credentials §Backing up your encryption key covers what happens if you lose the keyring entry.

A saved credential can fill only its node's credential fields, and its value is masked in run outputs, logs and errors; see Credentials §Using a credential in a node. It is not tied to a destination: a workflow decides where a node sends it, so read the URL fields of a workflow you import before running it. See Known issues.

Running unattended under aerini-server uses a different mechanism entirely, environment variables instead of the encrypted store, covered in Credentials §Running headless.

SSRF protection ​

Server-side request forgery is what happens when something that makes requests on your behalf, an HTTP node, a database connection, an AI provider's base URL, gets pointed at an address it shouldn't reach: your own machine's internal services, another box on your LAN, or a cloud provider's metadata endpoint, which often hands out credentials to whoever asks it directly.

Every node whose target is a URL or connection string you supply, HTTP Request, Send Email's SMTP host, S3 Storage, AI Prompt/AI Agent's base URL, Database, and Image Generation's local-server option, checks that target before connecting. Two policies exist:

  • Strict (the default): blocks loopback (127.0.0.1, ::1), private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), and localhost itself.
  • AllowLocal: permits those same ranges, for the handful of nodes whose entire purpose is reaching a server on your own machine or LAN, where the address you type in is itself your explicit trust signal.

Under both policies, a fixed set of ranges is always blocked regardless of which one applies: link-local addresses (including the 169.254.169.254 cloud metadata address most providers use), the Azure IMDS endpoint specifically, AWS's IPv6 metadata address fd00:ec2::254 (which sits inside the unique-local range that AllowLocal otherwise permits), RFC 6598 shared address space, the IETF protocol assignments block 192.0.0.0/24 (which holds Oracle Cloud's metadata address), 0.0.0.0/8, the reserved 240.0.0.0/4, and broadcast, multicast, unspecified, and documentation ranges. An IPv6 address that embeds an IPv4 address (IPv4-mapped ::ffff:a.b.c.d, NAT64 64:ff9b::/96, 6to4 2002::/16, and the deprecated ::a.b.c.d) is checked as that IPv4 address under the same rules, so NAT64 to a public address still works while NAT64 to an internal one is blocked. 198.18.0.0/15 is not blocked, because fake-IP proxy tools resolve every domain into it. None of those are legitimate targets for a self-hosted server, so opting into AllowLocal doesn't reopen them.

NodePolicy
HTTP RequestStrict
Send Email (SMTP host)Strict
S3 Storage (AWS, R2)Strict
S3 Storage (MinIO)AllowLocal
Database (Postgres, MySQL, Redis)Strict, unless its own Allow Local field is turned on
AI Prompt, AI Agent (base_url)AllowLocal
Image Generation (Automatic1111, ComfyUI)AllowLocal
Image Generation (Flux and other cloud providers)Strict
.wasm plugin outbound HTTPStrict, always, not configurable per plugin

AllowLocal exists because a real, common use of Aerini is pointing AI Prompt at a locally running model server like Ollama, which listens on localhost by design. That's a legitimate target the strict policy would otherwise reject outright.

How rebinding is handled: an early check resolves a domain name and validates the result before the request is built, and that alone leaves a small window before the actual connection: a DNS server that returns a public address during the check and a private one moments later, DNS rebinding, could slip through. For HTTP Request, AI Prompt, AI Agent, Image Generation and the integration nodes (Slack, GitHub, Stripe, Telegram, Discord, Notion, Google Sheets, SendGrid, OAuth sign-in, Social Upload), Aerini closes that window by applying the same address filter again when it connects, to every address the name resolves to and to each redirect hop that is an IP address. A name with even one blocked address is refused, and the address that was validated is the one used. A refused target fails with SSRF_BLOCKED and is never retried. Redis, Send Email (SMTP) and S3 Storage with an R2 or MinIO endpoint get the same connect-time filter through their own connection libraries: Aerini looks the name up once, refuses it if any address is blocked, and dials only an address it validated, while TLS still checks the original host name. Send Email first tries each validated address with a TCP connection of up to 5 seconds, so an address that does not answer adds up to 5 seconds before the next one is tried. .wasm plugin outbound HTTP works the same way: the host is looked up once, a name with any blocked address is refused, and the connection goes to a validated address while TLS still checks the original host name. A lookup that takes longer than 10 seconds is refused. The aws S3 provider takes no user-supplied URL. These HTTP-based nodes ignore system proxy settings (HTTP_PROXY, HTTPS_PROXY, and the operating system's proxy), because a proxy looks the target up itself and the filter could not see it. If your network requires a proxy, set the environment variable AERINI_ALLOW_SYSTEM_PROXY=1; the nodes then use the proxy and only the early check applies, so treat that as the limit described next.

The limits this can't close: Postgres and MySQL database connections are still checked only before they connect, so a DNS server that answers with a public address during the check and a private one at connect time can still redirect them. The database library (sqlx) uses one host value both as the address to dial and as the name its TLS check and server-name indication use. Dialling a validated IP instead would break verify-full certificate checks and hosts that route by server name, and the only clean split (a Unix socket plus a local relay) is Unix-only. What narrows the exposure is the TLS mode in the connection URL. With sslmode=verify-full (Postgres) or ssl-mode=VERIFY_IDENTITY (MySQL) the server must present a certificate for the name you configured, so a rebound internal address that cannot do that fails the handshake before any credentials or queries are sent; it can still see a connection attempt, which is enough to probe whether a port is open. Aerini checks against the public CA roots, and a private CA goes in the URL as sslrootcert. The other modes, including the default, do not check the name, so the window stays open there. This is a limit of the database library, not a bug specific to Aerini. If you're exposing anything that accepts outside input into a Database node, use verify-full or VERIFY_IDENTITY where the server supports it, and add a host-level egress firewall that blocks outbound connections to the same ranges regardless of what any application-layer check decides. A minimal nftables example:

table inet aerini_egress {
  chain output {
    type filter hook output priority 0; policy accept;
    ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16 } drop
  }
}

That example covers IPv4 only; if your deployment also has IPv6 reachable, add an equivalent ip6 daddr rule for ::1/128 and fc00::/7, since Aerini's own check blocks both address families. Adjust to your own setup (a rule scoped to the aerini-server process or its container, rather than the whole host, is usually preferable), and keep 169.254.169.254 explicitly covered if your cloud provider uses a metadata endpoint outside the 169.254.0.0/16 block shown above. aerini-server warns on startup if it's bound to a non-loopback address without acknowledging the remaining gap; see Hardening a server deployment below for the flag that silences that warning once a firewall is actually in place.

Dangerous nodes ​

Shell Command, Code (JS), Database, and every .wasm plugin node are treated as "dangerous": on the desktop app, that means a confirmation dialog and a canvas warning badge, detailed in the Nodes Reference. Running one of those nodes unattended (scheduled or webhook-triggered) skips that dialog entirely and only logs a warning, covered in Background Runs §Unattended execution and dangerous nodes. Neither of those is this page's job to restate; what belongs here is what changes once the same node types run under aerini-server instead of the desktop app.

On aerini-server, all three of Shell Command, Code (JS), and Database execution are disabled by default, in both serve and api mode. Each has its own opt-in flag, --allow-shell, --allow-code, --allow-database, and a workflow containing one of those node types simply fails that node at run time until the matching flag is passed. This is the inverse of the desktop app's posture: desktop trusts you by default since you're the only one who can reach it, while a server assumes it might be reachable by more than just you and asks you to opt back in per capability, one flag at a time, after you've actually reviewed what the workflow does. Database execution carries an extra reason to stay off by default beyond SSRF surface: sqlx-mysql's RSA timing side-channel (RUSTSEC-2023-0071) is a real, tracked advisory, not a hypothetical.

--allow-code alone still leaves Code (JS) running the full, unrestricted Node.js runtime described above, matching desktop's default. Add --code-sandbox to change that: with it set, the ESM module loader blocks importing fs, net, child_process, module, v8, inspector, and similar built-ins, eval and new Function are disabled outright, and the global fetch/WebSocket/XMLHttpRequest objects are removed before your code runs (they're built-in globals in modern Node, so blocking the import path alone wouldn't catch them). v8 and inspector are blocked for the same reason as the rest of the list even though neither is a filesystem or network module by name: v8.writeHeapSnapshot() writes a script-influenced file to an arbitrary path, and inspector.open() starts a debugger session that executes code without going through eval. process.getBuiltinModule() gets the same treatment as fetch: it's a process global that returns a builtin module — including module, whose createRequire() hands back a full CommonJS require() — without ever calling the ESM loader's resolve hook, so it's overridden with the same blocklist rather than left as an unguarded path to everything above. process.binding(), process._linkedBinding(), and process.dlopen() are removed outright rather than name-filtered: they hand back the same raw filesystem and process-spawning capability the blocklist above targets, but reach it through Node's internal binding layer instead of the module system, bypassing the loader hook entirely regardless of which module names it blocks. tls, wasi, trace_events, node:sqlite and node:test are blocked too, since each reaches the network, the filesystem or process spawning without importing a module from the list above (tls.connect() opens raw network connections without importing net), and process.kill() and process.report are removed so sandboxed code can neither signal other processes nor write diagnostic reports to a path of its choosing. None of that is a byte-for-byte replacement for OS-level isolation on its own; it's paired with resource limits, but those limits are platform-dependent:

  • Linux: the sandbox is fully enforced, including hard CPU and memory caps (setrlimit) on the subprocess. The memory cap is --max-code-memory-mb (default 512 MB) for the script itself, plus 2 GB of address space that Node.js reserves at startup and cannot start without; the 2 GB is added automatically. On Linux arm64 (aarch64) the added amount is 4 GB, because the startup reservation was measured on x86_64 only; if a sandboxed Code node still fails to start on arm64 with a V8 "out of memory" error, report it with the output of the check in PLAN.md (item 8).
  • macOS: the module-import restrictions apply, but the CPU and memory caps don't; a runaway script can still exhaust host resources, bounded only by the node's own timeout (max 60 seconds). aerini-server refuses to start with --allow-code --code-sandbox on macOS unless you also pass --i-acknowledge-partial-sandbox, specifically so this gap can't be enabled silently.
  • Windows: neither the module-import restrictions nor the resource caps are implemented. The server itself still starts; the refusal happens per node instead, the first time a workflow actually runs a Code (JS) node with --code-sandbox active. That node call fails outright rather than silently running unsandboxed, so a bad config surfaces at first use, not at server startup the way it does on macOS.

For a genuinely multi-user API deployment where --allow-code has to be on, Linux is the only platform where --code-sandbox provides a real boundary.

The plugin (.wasm) sandbox boundary ​

Plugins are WebAssembly components, compiled against WASI Preview 2, loaded from a directory you point Aerini at (plugin_dir on the server, an install flow on desktop). Each execution gets a fresh sandbox instance, never reused between runs, so one failed or misbehaving execution can't leave state behind for the next. Inside that sandbox:

  • Memory is capped at 64 MiB per execution, counting all of a plugin's memories together, and a plugin's result is capped at 10 MiB.
  • CPU time is capped at roughly 30 seconds of wall-clock execution, enforced independently of the node's own logic (a busy-loop gets killed, not just a slow network call). Each phase of a plugin's outbound request (connect, first byte, gap between body chunks) is held to 30 seconds whatever the plugin asks for, one request to 60 seconds in total, and a call that is still running after 120 seconds is stopped.
  • Filesystem access is fully denied. A plugin gets no preopened directories at all; any file operation it attempts fails. The one exception is the optional fs-watch import, which reports file names, sizes and change times for absolute paths the plugin names, never file contents; with --file-sandbox-dir set, it is limited to that directory.
  • Outbound HTTP is the only network access a plugin has, and every request it makes is checked against the same Strict SSRF policy the Database and HTTP Request nodes enforce, described above. The host is looked up once and the connection goes to the validated address, so DNS rebinding does not apply to plugins. There's no per-plugin opt-out and no AllowLocal option for plugins.

What sandboxing here does not do: protect a credential from a plugin you've deliberately wired one into. If a workflow connects a saved credential to a plugin node's input, the plugin receives that credential's actual resolved value, the same as any built-in node would, not just an ID. The sandbox limits where a plugin's own code can reach on its own; it says nothing about what a plugin does with data your workflow hands it directly. Treat a .wasm plugin credential connection with the same judgment you'd apply to any other place a secret changes hands.

Publishers can optionally sign a plugin file with an Ed25519 signature (<name>.wasm.sig, checked against a BLAKE3 hash of the file), and the desktop app's plugin panel shows whether a signature verified. An unsigned plugin loads exactly the same as a signed one. A signature that is present but wrong (the file does not match its signed checksum, or the signature is invalid or malformed) is refused at load and at install, and the desktop app refuses to install an unsigned file, or one signed with a different key, over a plugin it previously installed with a verified signature. Don't treat "no warning shown" as equivalent to "this plugin was vetted"; it currently just means the sandbox limits above are all that stand between the plugin and your machine.

A .wasm plugin node is flagged "dangerous" in the UI alongside Shell Command, Code, and Database, the same confirmation dialog and badge described above.

Trigger plugins, a plugin that supplies its own long-running trigger instead of running once per execution, are the one exception to the CPU cap: since they're meant to stay alive listening for events indefinitely, no wall-clock deadline is applied to them, only to the one-shot execute() path above. The 64 MiB memory cap still applies. They get the same SSRF-filtered outbound HTTP as action plugins and no additional network grant beyond that.

Hardening a server deployment ​

aerini-server has two modes, covered for setup in Background Runs §Beyond the desktop app and in full in Server Deployment: serve runs one workflow exported from the desktop app, api runs a full multi-workflow REST server. Both default to binding 127.0.0.1 only, both default every dangerous node type to disabled, and both print an explicit warning to the console if you change either of those defaults without acknowledging the tradeoff.

serve mode authenticates through an optional run_secret, set when you export the workflow from the desktop app and stored as an argon2id hash in aerini-server.json. Without one set, the status page (workflow name, run counts, timestamps) is still visible to anyone who can reach the port, though the Run Now button and authenticated log endpoints stay disabled either way. If that config file was exported before Aerini 0.3, its secret may still be a legacy BLAKE3 hash (or, from before 0.2, the secret in plain text), which the server refuses to start with by default (BLAKE3 is fast, not brute-force resistant) unless you pass --allow-legacy-run-secret; re-exporting from the desktop app upgrades it to argon2id instead. On Unix systems, the server also warns if that config file is readable by anyone but its owner, since it holds that hash; chmod 600 it.

api mode authenticates every request with a bearer token, randomly generated on first run if you don't set one, and supports additional scoped tokens limited to read, write, or admin, so an integration you hand a token to can't do more than it needs. A token can also be confined to specific workflows, in which case it cannot read, run, change or delete any other workflow, or touch the server-wide credentials; see Per-token workflow ACL. Local origins are always allowed to call the API; anything else needs an explicit --allow-origin.

A short list of flags matters most for an actual exposure decision, beyond the dangerous-node and code-sandbox flags already covered above:

  • --bind: stays 127.0.0.1 until you explicitly set it to something reachable from outside the machine. Don't change this before the rest of this list is in place.
  • --trusted-proxy-count: if you're behind a reverse proxy (nginx, Caddy, a load balancer), this has to match the number of proxy hops exactly, or per-IP rate limiting reads the proxy's address for every client instead of the real one.
  • --file-sandbox-dir: without it, a File node can read or write any path the server process itself can reach. Setting it restricts every File, Save to Folder, and SQLite Database node in every workflow to that one directory tree: symlinks are followed only when they resolve inside it, a link that points nowhere is refused, and on Windows UNC paths are refused. Recommended for anything other than a fully trusted single-user box.
  • --ssrf-firewall-acknowledged: suppresses the startup warning about the remaining DNS-rebinding gap described above. Pass it only after the egress firewall rule is actually in place, not to make the warning go away.

The full flag reference, every option for both subcommands, lives in Server CLI Reference; this section covers the ones that change your actual exposure, not the complete list.

See also ​

  • Credentials, for the full encryption, key-backup, and headless-credential model
  • Nodes Reference, for the dangerous-node confirmation dialog and every node's own fields
  • Background Runs, for how unattended runs interact with dangerous nodes and for exporting a workflow to aerini-server
  • Troubleshooting, for when a node fails against a blocked SSRF target rather than a question about the policy itself
  • FAQ, for the short version of the no-telemetry and update answers