VYPR
Medium severity6.7NVD Advisory· Published Mar 26, 2026· Updated Mar 31, 2026

CVE-2026-33623

CVE-2026-33623

Description

PinchTab is a standalone HTTP server that gives AI agents direct control over a Chrome browser. PinchTab v0.8.4 contains a Windows-only command injection issue in the orphaned Chrome cleanup path. When an instance is stopped, the Windows cleanup routine builds a PowerShell -Command string using a needle derived from the profile path. In v0.8.4, that string interpolation escapes backslashes but does not safely neutralize other PowerShell metacharacters. If an attacker can launch an instance using a crafted profile name and then trigger the cleanup path, they may be able to execute arbitrary PowerShell commands on the Windows host in the security context of the PinchTab process user. This is not an unauthenticated internet RCE. It requires authenticated, administrative-equivalent API access to instance lifecycle endpoints, and the resulting command execution inherits the permissions of the PinchTab OS user rather than bypassing host privilege boundaries. Version 0.8.5 contains a patch for the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/pinchtab/pinchtab/cmd/pinchtabGo
< 0.8.50.8.5
github.com/pinchtab/pinchtabGo
< 0.8.50.8.5

Affected products

1

Patches

1
25b3374bdcdf

fix: tighten instance API, runtime safety caps, and security posture docs (#368)

https://github.com/pinchtab/pinchtabluigiagentMar 21, 2026via ghsa
66 files changed · +1612 220
  • cmd/pinchtab/cmd_security.go+43 4 modified
    @@ -32,7 +32,11 @@ func init() {
     	})
     	securityCmd.AddCommand(&cobra.Command{
     		Use:   "down",
    -		Short: "Lower guards while keeping loopback bind and API auth enabled",
    +		Short: "Apply a documented security-reducing preset while keeping loopback bind and API auth enabled",
    +		Long: "Applies the guards-down preset for local operator workflows. " +
    +			"This is a documented, non-default, security-reducing configuration change: " +
    +			"sensitive endpoint families and attach are enabled, while IDPI protections are disabled. " +
    +			"Loopback bind and API authentication remain enabled, and attach host allowlisting stays local-only until you widen it explicitly.",
     		Run: func(cmd *cobra.Command, args []string) {
     			handleSecurityDownCommand()
     		},
    @@ -181,10 +185,17 @@ func promptSecurityEdit(cfg *config.RuntimeConfig, posture cli.SecurityPosture,
     func editSecurityCheck(cfg *config.RuntimeConfig, check cli.SecurityPostureCheck) (*config.RuntimeConfig, bool, error) {
     	switch check.ID {
     	case "bind_loopback":
    -		value, err := promptInput("Set server.bind:", cfg.Bind)
    +		value, err := promptInput("Set server.bind (127.0.0.1 keeps it local):", cfg.Bind)
     		if err != nil {
     			return nil, false, err
     		}
    +		if !isLoopbackBindValue(value) {
    +			fmt.Println()
    +			fmt.Println("  " + cli.StyleStdout(cli.WarningStyle, "Warning: server.bind is non-loopback"))
    +			fmt.Println("      " + cli.StyleStdout(cli.MutedStyle, "effect") + ": " + cli.StyleStdout(cli.ValueStyle, "may expose the server beyond the local machine unless an outer network boundary still restricts access"))
    +			fmt.Println("      " + cli.StyleStdout(cli.MutedStyle, "scope") + ": " + cli.StyleStdout(cli.ValueStyle, "documented, non-default, security-reducing override"))
    +			fmt.Println("      " + cli.StyleStdout(cli.MutedStyle, "hint") + ": " + cli.StyleStdout(cli.ValueStyle, "keep a token set and review reverse proxy or port-publishing behavior explicitly"))
    +		}
     		return workflow.UpdateValue("server.bind", value)
     	case "api_auth_enabled":
     		picked, err := promptSelect("API authentication", []menuOption{
    @@ -230,10 +241,17 @@ func editSecurityCheck(cfg *config.RuntimeConfig, check cli.SecurityPostureCheck
     		}
     		return workflow.UpdateValue("security.attach.enabled", fmt.Sprintf("%t", picked == "enable"))
     	case "attach_local_only":
    -		value, err := promptInput("Set security.attach.allowHosts (comma-separated):", strings.Join(cfg.AttachAllowHosts, ","))
    +		value, err := promptInput("Set security.attach.allowHosts (comma-separated; '*' disables host allowlisting):", strings.Join(cfg.AttachAllowHosts, ","))
     		if err != nil {
     			return nil, false, err
     		}
    +		if attachHostsContainsWildcard(value) {
    +			fmt.Println()
    +			fmt.Println("  " + cli.StyleStdout(cli.WarningStyle, "Warning: security.attach.allowHosts includes '*'"))
    +			fmt.Println("      " + cli.StyleStdout(cli.MutedStyle, "effect") + ": " + cli.StyleStdout(cli.ValueStyle, "disables host allowlisting and allows any reachable attach host with an allowed scheme"))
    +			fmt.Println("      " + cli.StyleStdout(cli.MutedStyle, "scope") + ": " + cli.StyleStdout(cli.ValueStyle, "documented, non-default, security-reducing override"))
    +			fmt.Println("      " + cli.StyleStdout(cli.MutedStyle, "hint") + ": " + cli.StyleStdout(cli.ValueStyle, "use only on isolated, operator-controlled networks"))
    +		}
     		return workflow.UpdateValue("security.attach.allowHosts", value)
     	case "idpi_whitelist_scoped":
     		value, err := promptInput("Set security.idpi.allowedDomains (comma-separated):", strings.Join(cfg.IDPI.AllowedDomains, ","))
    @@ -267,6 +285,24 @@ func editSecurityCheck(cfg *config.RuntimeConfig, check cli.SecurityPostureCheck
     	return cfg, false, nil
     }
     
    +func attachHostsContainsWildcard(value string) bool {
    +	for _, part := range strings.Split(value, ",") {
    +		if strings.TrimSpace(part) == "*" {
    +			return true
    +		}
    +	}
    +	return false
    +}
    +
    +func isLoopbackBindValue(value string) bool {
    +	switch strings.TrimSpace(strings.ToLower(value)) {
    +	case "", "127.0.0.1", "localhost", "::1":
    +		return true
    +	default:
    +		return false
    +	}
    +}
    +
     func applySecurityUp() (*config.RuntimeConfig, bool, error) {
     	configPath, changed, err := workflow.RestoreSecurityDefaults()
     	if err != nil {
    @@ -291,7 +327,10 @@ func applySecurityDown() (*config.RuntimeConfig, bool, error) {
     		return nextCfg, false, nil
     	}
     	fmt.Println(cli.StyleStdout(cli.WarningStyle, fmt.Sprintf("Guards down preset applied in %s", configPath)))
    -	fmt.Println(cli.StyleStdout(cli.MutedStyle, "Loopback bind and API auth remain enabled; sensitive endpoints and attach are enabled, IDPI is disabled."))
    +	fmt.Println(cli.StyleStdout(cli.WarningStyle, "This is a documented, non-default, security-reducing preset."))
    +	fmt.Println(cli.StyleStdout(cli.MutedStyle, "Loopback bind and API auth remain enabled; sensitive endpoints and attach are enabled, and IDPI protections are disabled."))
    +	fmt.Println(cli.StyleStdout(cli.MutedStyle, "Attach host allowlisting remains local-only. Widening allowHosts or enabling bridge schemes later is an additional explicit weakening."))
    +	fmt.Println(cli.StyleStdout(cli.MutedStyle, "Changing server.bind away from 127.0.0.1 later is also an additional explicit weakening unless another network boundary still constrains access."))
     	return nextCfg, true, nil
     }
     
    
  • cmd/pinchtab/cmd_security_test.go+40 0 modified
    @@ -3,6 +3,7 @@ package main
     import (
     	"io"
     	"os"
    +	"path/filepath"
     	"strings"
     	"testing"
     
    @@ -41,6 +42,45 @@ func TestHandleSecurityCommandDefaultConfigSkipsEmptySections(t *testing.T) {
     	}
     }
     
    +func TestApplySecurityDownPrintsExplicitRiskFraming(t *testing.T) {
    +	configPath := filepath.Join(t.TempDir(), "pinchtab", "config.json")
    +	t.Setenv("PINCHTAB_CONFIG", configPath)
    +
    +	fc := config.DefaultFileConfig()
    +	fc.Server.Token = "guarded-token"
    +	if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
    +		t.Fatalf("MkdirAll() error = %v", err)
    +	}
    +	if err := config.SaveFileConfig(&fc, configPath); err != nil {
    +		t.Fatalf("SaveFileConfig() error = %v", err)
    +	}
    +
    +	output := captureStdout(t, func() {
    +		cfg, changed, err := applySecurityDown()
    +		if err != nil {
    +			t.Fatalf("applySecurityDown() error = %v", err)
    +		}
    +		if !changed {
    +			t.Fatal("expected applySecurityDown() to change config")
    +		}
    +		if cfg == nil {
    +			t.Fatal("expected runtime config result")
    +		}
    +	})
    +
    +	for _, needle := range []string{
    +		"Guards down preset applied",
    +		"This is a documented, non-default, security-reducing preset.",
    +		"sensitive endpoints and attach are enabled, and IDPI protections are disabled.",
    +		"Attach host allowlisting remains local-only.",
    +		"Changing server.bind away from 127.0.0.1 later is also an additional explicit weakening",
    +	} {
    +		if !strings.Contains(output, needle) {
    +			t.Fatalf("expected output to contain %q\n%s", needle, output)
    +		}
    +	}
    +}
    +
     func captureStdout(t *testing.T, fn func()) string {
     	t.Helper()
     
    
  • cmd/pinchtab/root.go+2 0 modified
    @@ -6,6 +6,7 @@ import (
     
     	"github.com/pinchtab/pinchtab/internal/cli"
     	"github.com/pinchtab/pinchtab/internal/config"
    +	"github.com/pinchtab/pinchtab/internal/safelog"
     	"github.com/pinchtab/pinchtab/internal/server"
     	"github.com/spf13/cobra"
     )
    @@ -95,6 +96,7 @@ func menuListenStatus(cfg *config.RuntimeConfig) string {
     }
     
     func Execute() {
    +	safelog.InstallDefault()
     	if err := rootCmd.Execute(); err != nil {
     		fmt.Fprintln(os.Stderr, err)
     		os.Exit(1)
    
  • dashboard/src/pages/SettingsPage.tsx+56 14 modified
    @@ -267,6 +267,15 @@ export default function SettingsPage() {
         : [];
       const idpiWildcard = idpiAllowedDomains.includes("*");
       const idpiDomainsConfigured = idpiAllowedDomains.length > 0 && !idpiWildcard;
    +  const attachAllowedHosts = backendConfig
    +    ? backendConfig.security.attach.allowHosts
    +    : [];
    +  const attachWildcard = attachAllowedHosts.includes("*");
    +  const nonLoopbackBind = backendConfig
    +    ? !["127.0.0.1", "localhost", "::1", ""].includes(
    +        backendConfig.server.bind.trim().toLowerCase(),
    +      )
    +    : false;
     
       const updateBackendSection = <K extends keyof BackendConfig>(
         section: K,
    @@ -1273,15 +1282,35 @@ export default function SettingsPage() {
                       </SettingRow>
                       <SettingRow
                         label="Bind address"
    -                    description="Network interface the dashboard process binds to."
    +                    description="Network interface the dashboard process binds to. Keeping 127.0.0.1 or localhost limits direct reachability to the local machine."
                       >
    -                    <input
    -                      value={backendConfig.server.bind}
    -                      onChange={(e) =>
    -                        updateBackendSection("server", { bind: e.target.value })
    -                      }
    -                      className={fieldClass}
    -                    />
    +                    <div className="space-y-2">
    +                      <input
    +                        value={backendConfig.server.bind}
    +                        onChange={(e) =>
    +                          updateBackendSection("server", {
    +                            bind: e.target.value,
    +                          })
    +                        }
    +                        className={fieldClass}
    +                      />
    +                      {nonLoopbackBind ? (
    +                        <div className="rounded-sm border border-destructive/35 bg-destructive/10 px-3 py-2 text-xs leading-5 text-destructive/80">
    +                          A non-loopback bind is a documented, non-default,
    +                          security-reducing configuration change. It may expose
    +                          the server beyond the local machine unless another
    +                          network boundary still restricts access. Keep a token
    +                          set and review proxy or port-publishing behavior
    +                          explicitly.
    +                        </div>
    +                      ) : (
    +                        <div className="rounded-sm border border-warning/25 bg-warning/10 px-3 py-2 text-xs leading-5 text-warning">
    +                          Loopback bind keeps direct server reachability local.
    +                          Moving to <code>0.0.0.0</code> or another non-local
    +                          address widens the trust boundary.
    +                        </div>
    +                      )}
    +                    </div>
                       </SettingRow>
                       <SettingRow
                         label="API token"
    @@ -1363,7 +1392,9 @@ export default function SettingsPage() {
                       </SettingRow>
                       <SettingRow
                         label="Allowed attach hosts"
    -                    description="Comma-separated host allowlist for attach requests. Only include hosts you control and trust."
    +                    description={
    +                      'Comma-separated host allowlist for attach requests. Only include hosts you control and trust. Using "*" disables host allowlisting.'
    +                    }
                       >
                         <div className="space-y-2">
                           <input
    @@ -1380,11 +1411,22 @@ export default function SettingsPage() {
                             }
                             className={fieldClass}
                           />
    -                      <div className="rounded-sm border border-warning/25 bg-warning/10 px-3 py-2 text-xs leading-5 text-warning">
    -                        Hosts in this allowlist may be used for remote attach
    -                        requests. Broad or untrusted entries can expose external
    -                        Chrome sessions and browser contents.
    -                      </div>
    +                      {attachWildcard ? (
    +                        <div className="rounded-sm border border-destructive/35 bg-destructive/10 px-3 py-2 text-xs leading-5 text-destructive/80">
    +                          <code>allowHosts: ["*"]</code> is a documented,
    +                          non-default, security-reducing override. It disables
    +                          host allowlisting entirely and allows remote attach
    +                          requests to any reachable host with an allowed scheme.
    +                          Use it only on isolated, operator-controlled networks.
    +                        </div>
    +                      ) : (
    +                        <div className="rounded-sm border border-warning/25 bg-warning/10 px-3 py-2 text-xs leading-5 text-warning">
    +                          Hosts in this allowlist may be used for remote attach
    +                          requests. Broad or untrusted entries expand the trust
    +                          boundary and can expose external Chrome sessions and
    +                          browser contents.
    +                        </div>
    +                      )}
                         </div>
                       </SettingRow>
                       <SettingRow
    
  • docs/commands.md+1 1 modified
    @@ -180,7 +180,7 @@ pinchtab config set <path> <val>        # Set one file-config value
     pinchtab config patch <json>            # Merge JSON into the config file
     pinchtab security                       # Interactive security overview
     pinchtab security up                    # Apply stricter defaults
    -pinchtab security down                  # Relax defaults
    +pinchtab security down                  # Apply documented guards-down preset
     ```
     
     ## Global Flags
    
  • docs/dashboard.md+3 0 modified
    @@ -12,6 +12,9 @@ You can open the dashboard at:
     - `http://localhost:9867`
     - `http://localhost:9867/dashboard`
     
    +> [!WARNING]
    +> The dashboard is an operator/admin control surface, not a public or multi-user application. Do not expose it to untrusted users. Anyone who can use the dashboard can manage profiles, instances, configuration, and other browser-control capabilities that are enabled on that server.
    +
     ---
     
     ## Dashboard overview
    
  • docs/endpoints.md+4 0 modified
    @@ -308,6 +308,8 @@ POST /instances/{id}/tab
     Notes:
     
     - `/instances/start` and `/instances/launch` use `mode`, not `headless`
    +- `/instances/launch` is a compatibility alias over `/instances/start`
    +- create profiles explicitly with `POST /profiles`; `name` is no longer supported on `/instances/launch`
     - `/profiles/{id}/start` uses `headless`
     - attach routes are gated by `security.attach`
     
    @@ -348,6 +350,8 @@ Scheduler routes are only present when `scheduler.enabled` is true.
     
     Some endpoints are intentionally disabled unless the matching config allows them:
     
    +These gates are not ordinary feature toggles. Enabling them is a documented, non-default, security-reducing choice that widens the control surface available to callers.
    +
     - `/evaluate` and `/tabs/{id}/evaluate` -> `security.allowEvaluate`
     - `/download` and `/tabs/{id}/download` -> `security.allowDownload`
     - `/upload` and `/tabs/{id}/upload` -> `security.allowUpload`
    
  • docs/guides/attach-chrome.md+2 0 modified
    @@ -200,6 +200,8 @@ Recommended rules:
     - set `PINCHTAB_TOKEN` when the server is reachable outside localhost
     - only attach to CDP endpoints you trust
     
    +If you set `allowHosts` to `["*"]`, PinchTab accepts any reachable attach host with an allowed scheme. That is a documented, non-default, security-reducing override: it removes host allowlisting entirely and should only be used on isolated, operator-controlled networks.
    +
     Also remember:
     
     - Chrome DevTools gives powerful browser control
    
  • docs/guides/mcp-agents.md+4 1 modified
    @@ -2,6 +2,9 @@
     
     This guide walks through setting up PinchTab as an MCP tool server for AI coding assistants and agent frameworks.
     
    +> [!WARNING]
    +> When you connect an MCP client to PinchTab, that client is exercising the same privileged control plane as the dashboard, API, and remote CLI. Only trusted operators and trusted agent systems should be allowed to use it. If you are unsure whether a non-local or partially exposed deployment is safe, stop and review [Security](security.md) before proceeding.
    +
     ## What is MCP?
     
     The [Model Context Protocol](https://modelcontextprotocol.io/) is an open standard for connecting AI models to external tools. PinchTab implements an MCP server that exposes 21 browser-control tools — navigation, interaction, screenshot, PDF export, and more — over a simple stdio interface that every major AI client supports.
    @@ -152,7 +155,7 @@ security:
     
     Restart PinchTab after changing this setting.
     
    -> **Warning:** Enabling evaluate allows the agent (and any page it visits) to run arbitrary JavaScript in the browser. Only enable this on trusted networks with a token set.
    +> **Warning:** Enabling evaluate is a documented, non-default, security-reducing configuration change. It allows the agent (and any page it visits) to run arbitrary JavaScript in the browser. Only enable it on trusted networks with a token set.
     
     ## Connecting to a Remote PinchTab
     
    
  • docs/guides/multi-instance.md+1 1 modified
    @@ -40,7 +40,7 @@ pinchtab instance start --mode headed --port 9999
     
     Notes:
     
    -- `POST /instances/launch` still exists as a compatibility endpoint, but `POST /instances/start` is the clearer primary form.
    +- `POST /instances/launch` still exists as a compatibility endpoint, but it now follows the same semantics as `POST /instances/start`.
     - If you omit `profileId`, PinchTab creates a managed instance with an auto-generated profile name.
     - Starting an instance is only optional in workflows that use shorthand routes with auto-launch behavior, such as the `simple` strategy. In `explicit`, you should assume you need to start one yourself.
     
    
  • docs/guides/remote-bridge-orchestrator.md+5 0 modified
    @@ -102,6 +102,9 @@ Important notes:
     - `allowHosts` must include the remote bridge host
     - `allowSchemes` must include `http` or `https` for bridge attachment
     - `ws` and `wss` are still used for CDP attachment
    +- `baseUrl` must be a bare bridge origin; do not include credentials, query strings, fragments, or a path
    +
    +If you use `allowHosts: ["*"]`, the orchestrator will accept any reachable bridge host with an allowed scheme. That is a documented, non-default, security-reducing override: it removes host allowlisting entirely and should only be used on isolated, operator-controlled networks.
     
     If you leave `allowSchemes` as only `ws,wss`, `attach-bridge` will be rejected.
     
    @@ -121,6 +124,8 @@ pinchtab config set server.token bridge-secret-token
     pinchtab bridge
     ```
     
    +This non-loopback bind is a documented, non-default, security-reducing deployment change. It is appropriate here only because the bridge must be reachable from the orchestrator. Keep the bridge token set and expose the port only on a controlled network boundary.
    +
     Example bridge origin:
     
     ```text
    
  • docs/guides/security.md+26 0 modified
    @@ -6,6 +6,11 @@ PinchTab's default and primary deployment model is local-first: one user, one ma
     
     If you run PinchTab on a different machine, do so only if you understand the security model you are operating. Prefer a private or otherwise closed network, avoid exposing the service directly to the public internet, and keep high-risk capabilities disabled unless they are required for that deployment. If they must be enabled, restrict them so only the minimum trusted systems that need them can reach them.
     
    +> [!WARNING]
    +> PinchTab's dashboard, HTTP API, remote CLI targeting, MCP integrations, and automation routes are all part of the same privileged control plane. They are intended for trusted operators and trusted systems only. Do not expose them to untrusted users, untrusted client systems, or the public internet.
    +>
    +> If you are unsure whether a non-local or partially exposed deployment is safe, do not expose it yet. Review this guide first and use the private security contact path in `SECURITY.md` before proceeding.
    +
     The default security posture is:
     
     - `server.bind = 127.0.0.1`
    @@ -42,13 +47,29 @@ This means there are two independent questions:
     
     Both matter.
     
    +## Trust Boundary
    +
    +The important operational rule is simple:
    +
    +- if a person or system should not be allowed to control browser state, profiles, configuration, attachments, or sensitive endpoint families, it should not be able to reach PinchTab and it should not be given credentials for PinchTab
    +
    +That includes:
    +
    +- the browser dashboard
    +- direct HTTP API clients
    +- CLI usage against a remote server with `--server`
    +- MCP clients, plugins, scripts, and other automation layers built on top of the API
    +
    +These are different interfaces to the same control plane, not separate trust domains.
    +
     ## Advanced Deployments
     
     If you intentionally run PinchTab beyond the default local setup, the minimum operator checklist is:
     
     - keep `server.token` set to a strong random value
     - narrow network reachability with a trusted network boundary, VPN, firewall, or reverse proxy
     - add TLS at the proxy or transport layer when traffic leaves the local machine
    +- enable `server.trustProxyHeaders` only when a trusted reverse proxy is actually stripping and rebuilding `Forwarded` / `X-Forwarded-*` headers for you
     - keep sensitive endpoint families disabled unless they are explicitly needed, and if they are enabled, restrict them to the minimum trusted callers or network paths that must reach them
     - scope `security.attach` and `security.idpi` deliberately for the remote topology you are operating
     
    @@ -151,9 +172,14 @@ If you enable attach:
     - keep `allowHosts` narrowly scoped
     - prefer local-only hosts unless external Chrome targets or remote bridges are intentional
     - only attach to browsers and CDP endpoints you trust
    +- `allowHosts: ["*"]` is a documented, non-default, security-reducing override. It disables host allowlisting entirely and allows any reachable attach host with an allowed scheme. Use it only on isolated, operator-controlled networks.
     
     If you use `POST /instances/attach-bridge`, `security.attach.allowSchemes` must also include `http` or `https`.
     
    +`security.attach.allowSchemes` and `security.attach.enabled` still apply when `allowHosts` contains `"*"`, but host allowlisting no longer provides protection in that configuration.
    +
    +For `attach-bridge`, `baseUrl` should be a bare bridge origin such as `http://bridge.internal:9868`. Do not include credentials, query strings, fragments, or a path.
    +
     ## IDPI
     
     IDPI stands for Indirect Prompt Injection defense.
    
  • docs/guides/tailscale-bridge-orchestrator.md+5 0 modified
    @@ -144,6 +144,8 @@ pinchtab config set server.token bridge-secret-token
     pinchtab bridge
     ```
     
    +This non-loopback bind is a documented, non-default, security-reducing deployment change. It is appropriate here only because the bridge is intended to be reachable on your tailnet. Keep the bridge token set and do not publish the port beyond that controlled network boundary.
    +
     If you are using a daemon or service manager, ensure the config file has `bind: "0.0.0.0"`.
     
     The first common mistake is to leave the bridge on the default localhost bind. When that happens:
    @@ -202,6 +204,9 @@ Important details:
     - `allowHosts` must contain the exact hostname or IP you plan to use in `baseUrl`
     - `allowSchemes` must include `http` or `https` for `attach-bridge`
     - `ws` and `wss` remain relevant for CDP attach, not bridge attach
    +- `baseUrl` must be a bare origin such as `https://bridge-host:9868`; do not include credentials, query strings, fragments, or a path
    +
    +Using `allowHosts: ["*"]` is a documented, non-default, security-reducing override. It disables host validation and allows attachment to any reachable bridge host with an allowed scheme. Use it only on isolated, operator-controlled networks.
     
     The second common mistake is to accidentally configure `allowHosts` as one comma-separated string instead of a real JSON array. It must be:
     
    
  • docs/mcp.md+3 0 modified
    @@ -2,6 +2,9 @@
     
     PinchTab includes a native [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that lets AI agents control the browser through MCP over stdio.
     
    +> [!WARNING]
    +> The MCP server is part of PinchTab's privileged control plane. It is intended for trusted operators and trusted agent systems only. Do not expose it to untrusted users, untrusted client systems, or the public internet. If you are unsure how to secure a non-local deployment, review [Security](guides/security.md) and use the private security contact path in `SECURITY.md` before exposing the service.
    +
     ## Quick Start
     
     1. Start PinchTab in server or bridge mode:
    
  • docs/reference/cli.md+4 0 modified
    @@ -7,6 +7,8 @@
     
     Use the menu when you want a guided local control surface. Use direct commands when you want shell history, scripts, or remote targeting with `--server`.
     
    +When you target a remote server with `--server`, the CLI is exercising the same privileged control plane as the dashboard and HTTP API. Do not use it as an access path for untrusted users or untrusted systems. For deployment guidance, see [Security](../guides/security.md).
    +
     ## Interactive Menu
     
     Running `pinchtab` with no subcommand in an interactive terminal opens the menu. It does not immediately start the server.
    @@ -127,6 +129,8 @@ pinchtab security up
     pinchtab security down
     ```
     
    +`pinchtab security down` applies the documented, non-default, security-reducing preset for local operator workflows. It is not the baseline security posture.
    +
     For broader security guidance, see [Security Guide](../guides/security.md).
     
     ## Daemon
    
  • docs/reference/config.md+6 0 modified
    @@ -292,6 +292,8 @@ pinchtab config set server.token secret
     pinchtab server
     ```
     
    +Changing `server.bind` away from loopback is a documented, non-default, security-reducing deployment change. Use it only when remote reachability is intentional, keep a token set, and review the outer network boundary explicitly.
    +
     ### Custom Instance Port Range
     
     ```json
    @@ -317,6 +319,8 @@ pinchtab server
     }
     ```
     
    +`security.attach.allowHosts` is an allowlist. If you set it to `["*"]`, PinchTab accepts any reachable attach host with an allowed scheme. That is a documented, non-default, security-reducing override: it removes host allowlisting entirely and should only be used on isolated, operator-controlled networks.
    +
     ### Activity Retention
     
     ```json
    @@ -330,6 +334,8 @@ pinchtab server
     }
     ```
     
    +`server.trustProxyHeaders` should stay `false` unless PinchTab is behind a trusted reverse proxy that overwrites `Forwarded` and `X-Forwarded-*` headers. Do not enable it on direct-exposure deployments or behind proxies that pass client-supplied forwarding headers through unchanged.
    +
     ## Legacy Flat Format
     
     Older flat config is still accepted for backward compatibility:
    
  • docs/reference/eval.md+2 0 modified
    @@ -2,6 +2,8 @@
     
     Run JavaScript in the current tab. This endpoint is disabled unless evaluation is explicitly enabled in config.
     
    +Enabling `security.allowEvaluate` is a documented, non-default, security-reducing configuration change. It allows arbitrary JavaScript execution in page context and should only be used on trusted systems with authentication and network exposure reviewed explicitly.
    +
     ```bash
     curl -X POST http://localhost:9867/evaluate \
       -H "Content-Type: application/json" \
    
  • docs/reference/instances.md+28 6 modified
    @@ -20,6 +20,23 @@ pinchtab instances
     
     `pinchtab instances` is the simplest way to inspect the current fleet from the CLI.
     
    +Response shape:
    +
    +```json
    +[
    +  {
    +    "id": "inst_0a89a5bb",
    +    "profileId": "prof_278be873",
    +    "profileName": "instance-1741410000000",
    +    "port": "9999",
    +    "headless": false,
    +    "status": "running"
    +  }
    +]
    +```
    +
    +`GET /instances` returns a bare JSON array, not an envelope like `{"instances":[...]}`.
    +
     ## Start An Instance
     
     ### `POST /instances/start`
    @@ -49,23 +66,25 @@ Notes:
     
     ### `POST /instances/launch`
     
    -Use `/instances/launch` when you want to launch by profile name or `profileId` through the compatibility route.
    +`/instances/launch` is a compatibility alias for `/instances/start`.
     
     ```bash
     curl -X POST http://localhost:9867/instances/launch \
       -H "Content-Type: application/json" \
    -  -d '{"name":"work","mode":"headed"}'
    +  -d '{"profileId":"prof_278be873","mode":"headed"}'
     ```
     
     Request body:
     
    -- `name`: optional profile name
    -- `profileId`: optional profile ID
    +- `profileId`: optional existing profile ID or existing profile name
     - `mode`: optional; `headed` or headless by default
     - `port`: optional
     - `extensionPaths`: optional array of extension paths
     
    -Important: `/instances/launch` does not read a `headless` field. Use `mode:"headed"` when you want a headed browser.
    +Important:
    +
    +- `/instances/launch` does not read a `headless` field. Use `mode:"headed"` when you want a headed browser.
    +- `name` is no longer supported on `/instances/launch`. Create the profile first via `POST /profiles`, then use the returned `id` as `profileId`.
     
     ## Get One Instance
     
    @@ -161,6 +180,8 @@ Notes:
     
     - there is no CLI attach command
     - attach is allowed only when enabled in config under `security.attach`
    +- `security.attach.allowHosts` must allow the `cdpUrl` host
    +- `allowHosts: ["*"]` is a documented, non-default, security-reducing override. It disables host allowlisting entirely and allows any reachable CDP host with an allowed scheme. Use it only on isolated, operator-controlled networks.
     
     ## Attach An Existing Bridge
     
    @@ -176,6 +197,7 @@ curl -X POST http://localhost:9867/instances/attach-bridge \
     
     Notes:
     
    -- `baseUrl` must point at a running PinchTab bridge and must not include a path
    +- `baseUrl` must be a bare bridge origin; do not include credentials, query strings, fragments, or a path
     - the orchestrator performs a health check before registering it
     - `security.attach.allowHosts` must allow the bridge host
    +- `allowHosts: ["*"]` is a documented, non-default, security-reducing override. It disables host allowlisting entirely and allows any reachable bridge host with an allowed scheme. Use it only on isolated, operator-controlled networks.
    
  • docs/reference/mcp-tools.md+1 1 modified
    @@ -47,7 +47,7 @@ Selector forms include:
     
     | Tool | Key Parameters | Notes |
     | --- | --- | --- |
    -| `pinchtab_eval` | `expression` required, `tabId` | Requires `security.allowEvaluate` |
    +| `pinchtab_eval` | `expression` required, `tabId` | Requires `security.allowEvaluate` (documented non-default JS-execution opt-in) |
     | `pinchtab_pdf` | `tabId`, `landscape`, `scale`, `pageRanges` | Returns base64-encoded PDF content |
     | `pinchtab_find` | `query` required, `tabId` | Semantic element search |
     
    
  • internal/activity/activity.go+1 1 modified
    @@ -19,7 +19,7 @@ const (
     	defaultSessionIdleTimeout = 30 * time.Minute
     	defaultQueryLimit         = 200
     	maxQueryLimit             = 1000
    -	defaultRetentionDays      = 30
    +	defaultRetentionDays      = 1
     )
     
     type Config struct {
    
  • internal/bridge/cleanup_windows.go+9 5 modified
    @@ -6,18 +6,22 @@ import (
     	"bytes"
     	"fmt"
     	"log/slog"
    +	"os"
     	"os/exec"
     	"strconv"
     	"strings"
     )
     
    +const findPIDsByPowerShellScript = `$needle = $env:PINCHTAB_NEEDLE
    +if ([string]::IsNullOrEmpty($needle)) { exit 0 }
    +Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" |
    +Where-Object { $_.CommandLine -and $_.CommandLine.Contains($needle) } |
    +Select-Object -ExpandProperty ProcessId`
    +
     // findPIDsByPowerShell finds Chrome PIDs whose command line contains the needle.
     func findPIDsByPowerShell(needle string) []int {
    -	escaped := strings.ReplaceAll(needle, `\`, `\\`)
    -	cmd := exec.Command("powershell", "-NoProfile", "-Command",
    -		fmt.Sprintf(`Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | `+
    -			`Where-Object { $_.CommandLine -like '*%s*' } | `+
    -			`Select-Object -ExpandProperty ProcessId`, escaped))
    +	cmd := exec.Command("powershell", "-NoProfile", "-Command", findPIDsByPowerShellScript)
    +	cmd.Env = append(os.Environ(), "PINCHTAB_NEEDLE="+needle)
     
     	out, err := cmd.Output()
     	if err != nil {
    
  • internal/bridge/dialog_test.go+23 0 modified
    @@ -2,9 +2,12 @@ package bridge
     
     import (
     	"context"
    +	"strings"
     	"testing"
     )
     
    +const testMaxDialogTextBytes = 8 * 1024
    +
     func TestDialogManager_SetAndGetPending(t *testing.T) {
     	dm := NewDialogManager()
     
    @@ -135,6 +138,26 @@ func TestDialogState_Fields(t *testing.T) {
     	}
     }
     
    +func TestDialogManager_TruncatesOversizedDialogText(t *testing.T) {
    +	dm := NewDialogManager()
    +	dm.SetPending("tab1", &DialogState{
    +		Type:          "prompt",
    +		Message:       strings.Repeat("m", testMaxDialogTextBytes+256),
    +		DefaultPrompt: strings.Repeat("p", testMaxDialogTextBytes+256),
    +	})
    +
    +	got := dm.GetPending("tab1")
    +	if got == nil {
    +		t.Fatal("expected pending dialog")
    +	}
    +	if len(got.Message) > testMaxDialogTextBytes {
    +		t.Fatalf("message length = %d, want <= %d", len(got.Message), testMaxDialogTextBytes)
    +	}
    +	if len(got.DefaultPrompt) > testMaxDialogTextBytes {
    +		t.Fatalf("default prompt length = %d, want <= %d", len(got.DefaultPrompt), testMaxDialogTextBytes)
    +	}
    +}
    +
     func TestHandlePendingDialog_NoPending(t *testing.T) {
     	dm := NewDialogManager()
     	// No real CDP context needed — should fail before reaching CDP
    
  • internal/bridge/network_test.go+37 0 modified
    @@ -2,12 +2,19 @@ package bridge
     
     import (
     	"fmt"
    +	"strings"
     	"testing"
     	"time"
     
     	"github.com/pinchtab/pinchtab/internal/config"
     )
     
    +const (
    +	testMaxNetworkURLBytes         = 8 * 1024
    +	testMaxNetworkPostDataBytes    = 64 * 1024
    +	testMaxNetworkHeaderTotalBytes = 32 * 1024
    +)
    +
     func TestNetworkBuffer_AddAndGet(t *testing.T) {
     	buf := NewNetworkBuffer(3)
     
    @@ -70,6 +77,36 @@ func TestNetworkBuffer_Update(t *testing.T) {
     	}
     }
     
    +func TestNetworkBuffer_TruncatesOversizedFields(t *testing.T) {
    +	buf := NewNetworkBuffer(10)
    +	buf.Add(NetworkEntry{
    +		RequestID: "r1",
    +		URL:       "https://example.com/" + strings.Repeat("a", testMaxNetworkURLBytes),
    +		PostData:  strings.Repeat("b", testMaxNetworkPostDataBytes+1024),
    +		RequestHeaders: map[string]string{
    +			"X-Test": strings.Repeat("c", testMaxNetworkHeaderTotalBytes),
    +		},
    +	})
    +
    +	entry, ok := buf.Get("r1")
    +	if !ok {
    +		t.Fatal("expected entry to exist")
    +	}
    +	if len(entry.URL) > testMaxNetworkURLBytes {
    +		t.Fatalf("URL length = %d, want <= %d", len(entry.URL), testMaxNetworkURLBytes)
    +	}
    +	if len(entry.PostData) > testMaxNetworkPostDataBytes {
    +		t.Fatalf("PostData length = %d, want <= %d", len(entry.PostData), testMaxNetworkPostDataBytes)
    +	}
    +	totalHeaderBytes := 0
    +	for key, value := range entry.RequestHeaders {
    +		totalHeaderBytes += len(key) + len(value)
    +	}
    +	if totalHeaderBytes > testMaxNetworkHeaderTotalBytes {
    +		t.Fatalf("header bytes = %d, want <= %d", totalHeaderBytes, testMaxNetworkHeaderTotalBytes)
    +	}
    +}
    +
     func TestNetworkBuffer_Clear(t *testing.T) {
     	buf := NewNetworkBuffer(10)
     	buf.Add(NetworkEntry{RequestID: "r1"})
    
  • internal/bridge/observe/network.go+70 0 modified
    @@ -13,11 +13,25 @@ import (
     	"github.com/chromedp/cdproto/network"
     	"github.com/chromedp/chromedp"
     	"github.com/pinchtab/pinchtab/internal/config"
    +	"github.com/pinchtab/pinchtab/internal/sanitize"
     )
     
     // DefaultNetworkBufferSize is the default number of entries kept per tab.
     const DefaultNetworkBufferSize = 100
     
    +const (
    +	maxNetworkURLBytes          = 8 * 1024
    +	maxNetworkMethodBytes       = 32
    +	maxNetworkResourceTypeBytes = 64
    +	maxNetworkStatusTextBytes   = 512
    +	maxNetworkMimeTypeBytes     = 512
    +	maxNetworkErrorBytes        = 2 * 1024
    +	maxNetworkPostDataBytes     = 64 * 1024
    +	maxNetworkHeaderKeyBytes    = 256
    +	maxNetworkHeaderValueBytes  = 4 * 1024
    +	maxNetworkHeaderTotalBytes  = 32 * 1024
    +)
    +
     // NetworkEntry represents a single captured network request/response pair.
     type NetworkEntry struct {
     	RequestID       string            `json:"requestId"`
    @@ -67,6 +81,7 @@ func NewNetworkBuffer(size int) *NetworkBuffer {
     
     // Add inserts or updates a network entry.
     func (nb *NetworkBuffer) Add(entry NetworkEntry) {
    +	entry = normalizeNetworkEntry(entry)
     	nb.mu.Lock()
     
     	isNew := false
    @@ -140,6 +155,7 @@ func (nb *NetworkBuffer) Update(requestID string, fn func(*NetworkEntry)) {
     		return
     	}
     	fn(&nb.entries[idx])
    +	nb.entries[idx] = normalizeNetworkEntry(nb.entries[idx])
     }
     
     // List returns all entries, optionally filtered.
    @@ -455,3 +471,57 @@ func GetResponseBodyDirect(ctx context.Context, requestID string) (string, bool,
     
     	return body, base64Encoded, err
     }
    +
    +func normalizeNetworkEntry(entry NetworkEntry) NetworkEntry {
    +	entry.URL = sanitize.TruncateUTF8Bytes(entry.URL, maxNetworkURLBytes)
    +	entry.Method = sanitize.TruncateUTF8Bytes(entry.Method, maxNetworkMethodBytes)
    +	entry.ResourceType = sanitize.TruncateUTF8Bytes(entry.ResourceType, maxNetworkResourceTypeBytes)
    +	entry.StatusText = sanitize.TruncateUTF8Bytes(entry.StatusText, maxNetworkStatusTextBytes)
    +	entry.MimeType = sanitize.TruncateUTF8Bytes(entry.MimeType, maxNetworkMimeTypeBytes)
    +	entry.Error = sanitize.TruncateUTF8Bytes(entry.Error, maxNetworkErrorBytes)
    +	entry.PostData = sanitize.TruncateUTF8Bytes(entry.PostData, maxNetworkPostDataBytes)
    +	entry.RequestHeaders = normalizeNetworkHeaders(entry.RequestHeaders)
    +	entry.ResponseHeaders = normalizeNetworkHeaders(entry.ResponseHeaders)
    +	return entry
    +}
    +
    +func normalizeNetworkHeaders(headers map[string]string) map[string]string {
    +	if len(headers) == 0 {
    +		return nil
    +	}
    +
    +	remaining := maxNetworkHeaderTotalBytes
    +	normalized := make(map[string]string, len(headers))
    +	for key, value := range headers {
    +		if remaining <= 0 {
    +			break
    +		}
    +
    +		key = sanitize.TruncateUTF8Bytes(key, maxNetworkHeaderKeyBytes)
    +		if key == "" {
    +			continue
    +		}
    +
    +		valueLimit := maxNetworkHeaderValueBytes
    +		if max := remaining - len(key); max < valueLimit {
    +			valueLimit = max
    +		}
    +		if valueLimit <= 0 {
    +			break
    +		}
    +
    +		value = sanitize.TruncateUTF8Bytes(value, valueLimit)
    +		entryBytes := len(key) + len(value)
    +		if entryBytes <= 0 {
    +			continue
    +		}
    +
    +		normalized[key] = value
    +		remaining -= entryBytes
    +	}
    +
    +	if len(normalized) == 0 {
    +		return nil
    +	}
    +	return normalized
    +}
    
  • internal/bridge/tabs/dialog.go+18 3 modified
    @@ -8,8 +8,11 @@ import (
     
     	"github.com/chromedp/cdproto/page"
     	"github.com/chromedp/chromedp"
    +	"github.com/pinchtab/pinchtab/internal/sanitize"
     )
     
    +const maxDialogTextBytes = 8 * 1024
    +
     // DialogState represents a pending JavaScript dialog.
     type DialogState struct {
     	Type              string `json:"type"`
    @@ -33,7 +36,7 @@ func NewDialogManager() *DialogManager {
     func (dm *DialogManager) SetPending(tabID string, state *DialogState) {
     	dm.mu.Lock()
     	defer dm.mu.Unlock()
    -	dm.pending[tabID] = state
    +	dm.pending[tabID] = normalizeDialogState(state)
     }
     
     func (dm *DialogManager) GetPending(tabID string) *DialogState {
    @@ -66,11 +69,11 @@ func ListenDialogEvents(ctx context.Context, tabID string, dm *DialogManager, au
     	chromedp.ListenTarget(ctx, func(ev interface{}) {
     		switch e := ev.(type) {
     		case *page.EventJavascriptDialogOpening:
    -			state := &DialogState{
    +			state := normalizeDialogState(&DialogState{
     				Type:          string(e.Type),
     				Message:       e.Message,
     				DefaultPrompt: e.DefaultPrompt,
    -			}
    +			})
     			slog.Debug("dialog opened", "tabId", tabID, "type", e.Type)
     
     			if autoAccept {
    @@ -121,3 +124,15 @@ func HandlePendingDialog(ctx context.Context, tabID string, dm *DialogManager, a
     		Handled: true,
     	}, nil
     }
    +
    +func normalizeDialogState(state *DialogState) *DialogState {
    +	if state == nil {
    +		return nil
    +	}
    +
    +	copyState := *state
    +	copyState.Type = sanitize.TruncateUTF8Bytes(copyState.Type, 32)
    +	copyState.Message = sanitize.TruncateUTF8Bytes(copyState.Message, maxDialogTextBytes)
    +	copyState.DefaultPrompt = sanitize.TruncateUTF8Bytes(copyState.DefaultPrompt, maxDialogTextBytes)
    +	return &copyState
    +}
    
  • internal/cli/actions/actions_download.go+35 1 modified
    @@ -2,6 +2,7 @@ package actions
     
     import (
     	"encoding/base64"
    +	"encoding/json"
     	"fmt"
     	"net/http"
     	"net/url"
    @@ -11,6 +12,8 @@ import (
     	"github.com/pinchtab/pinchtab/internal/cli/apiclient"
     )
     
    +const downloadDataPreviewLimit = 256
    +
     func Download(client *http.Client, base, token string, args []string, output string) {
     	if len(args) < 1 {
     		cli.Fatal("Usage: pinchtab download <url> [-o <file>]")
    @@ -21,7 +24,18 @@ func Download(client *http.Client, base, token string, args []string, output str
     	params := url.Values{}
     	params.Set("url", targetURL)
     
    -	result := apiclient.DoGet(client, base, token, "/download", params)
    +	body := apiclient.DoGetRaw(client, base, token, "/download", params)
    +	if body == nil {
    +		return
    +	}
    +
    +	var result map[string]any
    +	if err := json.Unmarshal(body, &result); err != nil {
    +		fmt.Println(string(body))
    +		return
    +	}
    +
    +	printDownloadResult(result)
     
     	// If -o flag set, decode base64 and save to file
     	if output != "" {
    @@ -39,3 +53,23 @@ func Download(client *http.Client, base, token string, args []string, output str
     		fmt.Println(cli.StyleStdout(cli.SuccessStyle, fmt.Sprintf("Saved %s (%d bytes)", output, len(data))))
     	}
     }
    +
    +func printDownloadResult(result map[string]any) {
    +	view := make(map[string]any, len(result)+2)
    +	for k, v := range result {
    +		view[k] = v
    +	}
    +
    +	if b64, ok := view["data"].(string); ok && len(b64) > downloadDataPreviewLimit {
    +		view["data"] = b64[:downloadDataPreviewLimit] + "... (truncated)"
    +		view["dataLength"] = len(b64)
    +		view["dataTruncated"] = true
    +	}
    +
    +	formatted, err := json.MarshalIndent(view, "", "  ")
    +	if err != nil {
    +		fmt.Println("{}")
    +		return
    +	}
    +	fmt.Println(string(formatted))
    +}
    
  • internal/cli/actions/actions_download_test.go+64 0 added
    @@ -0,0 +1,64 @@
    +package actions
    +
    +import (
    +	"encoding/base64"
    +	"os"
    +	"path/filepath"
    +	"strings"
    +	"testing"
    +)
    +
    +func TestDownloadTruncatesPrintedBase64(t *testing.T) {
    +	payload := strings.Repeat("A", 400)
    +	m := newMockServer()
    +	m.response = `{"data":"` + payload + `","contentType":"image/png","size":123}`
    +	defer m.close()
    +
    +	output := captureStdout(t, func() {
    +		Download(m.server.Client(), m.base(), "", []string{"https://example.com/image.png"}, "")
    +	})
    +
    +	if !strings.Contains(output, `"dataTruncated": true`) {
    +		t.Fatalf("expected output to mark truncated data, got %q", output)
    +	}
    +	if !strings.Contains(output, `"dataLength": 400`) {
    +		t.Fatalf("expected output to include original data length, got %q", output)
    +	}
    +	if strings.Contains(output, `"`+payload+`"`) {
    +		t.Fatalf("expected output to avoid printing the full payload")
    +	}
    +	if !strings.Contains(output, "... (truncated)") {
    +		t.Fatalf("expected output to include truncation suffix, got %q", output)
    +	}
    +}
    +
    +func TestDownloadWithOutputSavesDecodedFileAndTruncatesPrintedBase64(t *testing.T) {
    +	data := []byte(strings.Repeat("image-bytes-", 40))
    +	encoded := base64.StdEncoding.EncodeToString(data)
    +
    +	m := newMockServer()
    +	m.response = `{"data":"` + encoded + `","contentType":"image/png","size":` + `480` + `}`
    +	defer m.close()
    +
    +	outFile := filepath.Join(t.TempDir(), "image.bin")
    +	output := captureStdout(t, func() {
    +		Download(m.server.Client(), m.base(), "", []string{"https://example.com/image.png"}, outFile)
    +	})
    +
    +	written, err := os.ReadFile(outFile)
    +	if err != nil {
    +		t.Fatalf("expected output file to be written: %v", err)
    +	}
    +	if string(written) != string(data) {
    +		t.Fatalf("unexpected file content")
    +	}
    +	if !strings.Contains(output, `"dataTruncated": true`) {
    +		t.Fatalf("expected truncated preview in output, got %q", output)
    +	}
    +	if !strings.Contains(output, "Saved "+outFile) {
    +		t.Fatalf("expected save confirmation, got %q", output)
    +	}
    +	if strings.Contains(output, `"`+encoded+`"`) {
    +		t.Fatalf("expected output to avoid printing the full base64 payload")
    +	}
    +}
    
  • internal/cli/actions/actions_system.go+25 26 modified
    @@ -5,6 +5,7 @@ import (
     	"fmt"
     	"github.com/pinchtab/pinchtab/internal/cli"
     	"github.com/pinchtab/pinchtab/internal/cli/apiclient"
    +	"io"
     	"log"
     	"net/http"
     	"os"
    @@ -17,17 +18,10 @@ func Health(client *http.Client, base, token string) {
     func Instances(client *http.Client, base, token string) {
     	body := apiclient.DoGetRaw(client, base, token, "/instances", nil)
     
    -	// Parse and format as JSON
    -	var instances []map[string]any
    -	if err := json.Unmarshal(body, &instances); err != nil {
    -		var envelope struct {
    -			Instances []map[string]any `json:"instances"`
    -		}
    -		if err := json.Unmarshal(body, &envelope); err != nil {
    -			fmt.Fprintf(os.Stderr, "Failed to parse instances: %v\n", err)
    -			os.Exit(1)
    -		}
    -		instances = envelope.Instances
    +	instances, err := decodeInstancesResponse(body)
    +	if err != nil {
    +		fmt.Fprintf(os.Stderr, "Failed to parse instances: %v\n", err)
    +		os.Exit(1)
     	}
     
     	// Transform to cleaner output format
    @@ -95,25 +89,30 @@ func getInstances(client *http.Client, base, token string) []map[string]any {
     	}
     	defer func() { _ = result.Body.Close() }()
     
    -	var data map[string]any
    -	if err := json.NewDecoder(result.Body).Decode(&data); err != nil {
    -		log.Printf("warning: error decoding instances response: %v", err)
    +	body, err := io.ReadAll(result.Body)
    +	if err != nil {
    +		log.Printf("warning: error reading instances response: %v", err)
    +		return nil
     	}
     
    -	if instances, ok := data["instances"].([]interface{}); ok {
    -		converted := make([]map[string]any, len(instances))
    -		for i, inst := range instances {
    -			if m, ok := inst.(map[string]any); ok {
    -				converted[i] = m
    -			}
    -		}
    -		return converted
    +	instances, err := decodeInstancesResponse(body)
    +	if err != nil {
    +		log.Printf("warning: error decoding instances response: %v", err)
    +		return nil
     	}
    -	return nil
    +	return instances
     }
     
    -// launchInstance launches a default instance
    +// launchInstance launches a managed instance for the requested profile.
     func launchInstance(client *http.Client, base, token string, profile string) {
    -	body := map[string]any{"profile": profile}
    -	apiclient.DoPost(client, base, token, "/instances/launch", body)
    +	body := map[string]any{"profileId": profile}
    +	apiclient.DoPost(client, base, token, "/instances/start", body)
    +}
    +
    +func decodeInstancesResponse(body []byte) ([]map[string]any, error) {
    +	var instances []map[string]any
    +	if err := json.Unmarshal(body, &instances); err == nil {
    +		return instances, nil
    +	}
    +	return nil, fmt.Errorf("expected /instances to return a JSON array")
     }
    
  • internal/cli/actions/actions_system_test.go+27 0 modified
    @@ -39,3 +39,30 @@ func TestNoAuthHeader(t *testing.T) {
     		t.Errorf("expected no auth header, got %q", auth)
     	}
     }
    +
    +func TestGetInstances_ArrayResponse(t *testing.T) {
    +	m := newMockServer()
    +	m.response = `[{"id":"inst_123","port":"9868","status":"running","headless":true}]`
    +	defer m.close()
    +	client := m.server.Client()
    +
    +	instances := getInstances(client, m.base(), "")
    +	if len(instances) != 1 {
    +		t.Fatalf("len(instances) = %d, want 1", len(instances))
    +	}
    +	if got, _ := instances[0]["id"].(string); got != "inst_123" {
    +		t.Fatalf("id = %q, want %q", got, "inst_123")
    +	}
    +}
    +
    +func TestGetInstances_EnvelopeResponseRejected(t *testing.T) {
    +	m := newMockServer()
    +	m.response = `{"instances":[{"id":"inst_456","port":"9869","status":"running","headless":false}]}`
    +	defer m.close()
    +	client := m.server.Client()
    +
    +	instances := getInstances(client, m.base(), "")
    +	if instances != nil {
    +		t.Fatalf("instances = %#v, want nil for legacy envelope response", instances)
    +	}
    +}
    
  • internal/cli/report/security.go+24 3 modified
    @@ -148,7 +148,7 @@ func AssessSecurityWarnings(cfg *config.RuntimeConfig) []SecurityWarning {
     		warnings = append(warnings, SecurityWarning{
     			ID:      "non_loopback_bind",
     			Message: "server exposed on a non-loopback bind address",
    -			Attrs:   []any{"bind", cfg.Bind, "hint", "prefer 127.0.0.1 or localhost unless remote access is intentional"},
    +			Attrs:   []any{"bind", cfg.Bind, "hint", "non-loopback bind is a documented, non-default, security-reducing choice; keep a token set and review reverse proxy or port-publishing boundaries explicitly"},
     		})
     	}
     
    @@ -190,11 +190,20 @@ func AssessSecurityWarnings(cfg *config.RuntimeConfig) []SecurityWarning {
     		}
     	}
     
    -	if attachAllowsNonLocalHosts(cfg.AttachAllowHosts) {
    +	if allowsAllAttachHosts(cfg.AttachAllowHosts) {
    +		warnings = append(warnings, SecurityWarning{
    +			ID:      "attach_wildcard_hosts",
    +			Message: "attach allowHosts disables host allowlisting",
    +			Attrs: []any{
    +				"allowHosts", cfg.AttachAllowHosts,
    +				"hint", "remove '*' and list only approved hosts; wildcard is an explicit security-reducing override for isolated, operator-controlled networks only",
    +			},
    +		})
    +	} else if attachAllowsNonLocalHosts(cfg.AttachAllowHosts) {
     		warnings = append(warnings, SecurityWarning{
     			ID:      "attach_external_hosts",
     			Message: "attach allowHosts includes non-local hosts",
    -			Attrs:   []any{"allowHosts", cfg.AttachAllowHosts, "hint", "keep security.attach.allowHosts limited to local addresses unless external Chrome instances are intentional"},
    +			Attrs:   []any{"allowHosts", cfg.AttachAllowHosts, "hint", "keep security.attach.allowHosts limited to approved hosts you operate; broad entries expand the remote attach trust boundary"},
     		})
     	}
     
    @@ -230,6 +239,15 @@ func allowsAllDomains(domains []string) bool {
     	return false
     }
     
    +func allowsAllAttachHosts(hosts []string) bool {
    +	for _, host := range hosts {
    +		if strings.TrimSpace(host) == "*" {
    +			return true
    +		}
    +	}
    +	return false
    +}
    +
     func attachAllowsNonLocalHosts(hosts []string) bool {
     	if len(hosts) == 0 {
     		return false
    @@ -268,6 +286,9 @@ func formatEndpointStatus(enabled []string) string {
     }
     
     func formatHostScope(hosts []string) string {
    +	if allowsAllAttachHosts(hosts) {
    +		return "wildcard (*)"
    +	}
     	if attachAllowsNonLocalHosts(hosts) {
     		return "external hosts allowed"
     	}
    
  • internal/cli/report/security_test.go+71 0 modified
    @@ -63,6 +63,15 @@ func TestAssessSecurityWarnings(t *testing.T) {
     				t.Fatalf("expected warning %q, got %+v", expected, warnings)
     			}
     		}
    +
    +		for _, warning := range warnings {
    +			if warning.ID != "non_loopback_bind" {
    +				continue
    +			}
    +			if len(warning.Attrs) < 4 || warning.Attrs[3] != "non-loopback bind is a documented, non-default, security-reducing choice; keep a token set and review reverse proxy or port-publishing boundaries explicitly" {
    +				t.Fatalf("unexpected non_loopback_bind warning attrs: %+v", warning)
    +			}
    +		}
     	})
     
     	t.Run("wildcard whitelist is warned", func(t *testing.T) {
    @@ -88,6 +97,39 @@ func TestAssessSecurityWarnings(t *testing.T) {
     		}
     	})
     
    +	t.Run("wildcard attach hosts are called out explicitly", func(t *testing.T) {
    +		cfg := &config.RuntimeConfig{
    +			Bind:             "127.0.0.1",
    +			Token:            "secret",
    +			AttachEnabled:    true,
    +			AttachAllowHosts: []string{"*"},
    +			AttachAllowSchemes: []string{
    +				"http",
    +				"https",
    +			},
    +		}
    +
    +		warnings := assessSecurityWarnings(cfg)
    +		ids := make(map[string]bool, len(warnings))
    +		var wildcardWarning SecurityWarning
    +		for _, warning := range warnings {
    +			ids[warning.ID] = true
    +			if warning.ID == "attach_wildcard_hosts" {
    +				wildcardWarning = warning
    +			}
    +		}
    +
    +		if !ids["attach_wildcard_hosts"] {
    +			t.Fatalf("expected wildcard attach warning, got %+v", warnings)
    +		}
    +		if ids["attach_external_hosts"] {
    +			t.Fatalf("expected wildcard attach warning to replace generic external-host warning, got %+v", warnings)
    +		}
    +		if wildcardWarning.Message != "attach allowHosts disables host allowlisting" {
    +			t.Fatalf("unexpected wildcard attach warning message: %+v", wildcardWarning)
    +		}
    +	})
    +
     	t.Run("disabled IDPI is warned", func(t *testing.T) {
     		cfg := &config.RuntimeConfig{
     			Bind:               "127.0.0.1",
    @@ -133,6 +175,35 @@ func TestAssessSecurityPosture(t *testing.T) {
     		}
     	})
     
    +	t.Run("wildcard attach scope is surfaced in posture detail", func(t *testing.T) {
    +		cfg := &config.RuntimeConfig{
    +			Bind:               "127.0.0.1",
    +			Token:              "secret",
    +			AttachAllowHosts:   []string{"*"},
    +			AttachAllowSchemes: []string{"http", "https"},
    +			IDPI: config.IDPIConfig{
    +				Enabled:        true,
    +				AllowedDomains: []string{"example.com"},
    +				StrictMode:     true,
    +				ScanContent:    true,
    +				WrapContent:    true,
    +			},
    +		}
    +
    +		posture := assessSecurityPosture(cfg)
    +		for _, check := range posture.Checks {
    +			if check.ID != "attach_local_only" {
    +				continue
    +			}
    +			if check.Detail != "wildcard (*)" {
    +				t.Fatalf("expected wildcard host scope detail, got %q", check.Detail)
    +			}
    +			return
    +		}
    +
    +		t.Fatal("attach_local_only check not found")
    +	})
    +
     	t.Run("exposed config drops posture score", func(t *testing.T) {
     		cfg := &config.RuntimeConfig{
     			Bind:             "0.0.0.0",
    
  • internal/config/config_file.go+1 1 modified
    @@ -34,7 +34,7 @@ func DefaultFileConfig() FileConfig {
     	attachEnabled := false
     	activityEnabled := true
     	activitySessionIdleSec := 1800
    -	activityRetentionDays := 30
    +	activityRetentionDays := 1
     	return FileConfig{
     		ConfigVersion: CurrentConfigVersion,
     		Server: ServerConfig{
    
  • internal/config/config_load.go+1 1 modified
    @@ -92,7 +92,7 @@ func Load() *RuntimeConfig {
     			Activity: ActivityConfig{
     				Enabled:        true,
     				SessionIdleSec: 1800,
    -				RetentionDays:  30,
    +				RetentionDays:  1,
     			},
     		},
     	}
    
  • internal/config/config_load_test.go+2 2 modified
    @@ -113,8 +113,8 @@ func TestLoadConfigDefaults(t *testing.T) {
     	if !cfg.Observability.Activity.Enabled {
     		t.Errorf("default Observability.Activity.Enabled = %v, want true", cfg.Observability.Activity.Enabled)
     	}
    -	if cfg.Observability.Activity.RetentionDays != 30 {
    -		t.Errorf("default Observability.Activity.RetentionDays = %d, want 30", cfg.Observability.Activity.RetentionDays)
    +	if cfg.Observability.Activity.RetentionDays != 1 {
    +		t.Errorf("default Observability.Activity.RetentionDays = %d, want 1", cfg.Observability.Activity.RetentionDays)
     	}
     }
     
    
  • internal/handlers/middleware_test.go+50 20 modified
    @@ -12,6 +12,12 @@ import (
     	"github.com/pinchtab/pinchtab/internal/httpx"
     )
     
    +func resetRateLimitStateForTests() {
    +	rateMu.Lock()
    +	rateBuckets = map[string][]time.Time{}
    +	rateMu.Unlock()
    +}
    +
     func TestAuthMiddleware_NoToken(t *testing.T) {
     	cfg := &config.RuntimeConfig{Token: ""}
     
    @@ -694,9 +700,8 @@ func TestRequestIDMiddleware_SetsHeader(t *testing.T) {
     }
     
     func TestRateLimitMiddleware_AllowsRequest(t *testing.T) {
    -	rateMu.Lock()
    -	rateBuckets = map[string][]time.Time{}
    -	rateMu.Unlock()
    +	resetRateLimitStateForTests()
    +	t.Cleanup(resetRateLimitStateForTests)
     
     	handler := RateLimitMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
     		w.WriteHeader(200)
    @@ -710,41 +715,69 @@ func TestRateLimitMiddleware_AllowsRequest(t *testing.T) {
     	}
     }
     
    -func TestRateLimitMiddleware_HealthAndMetricsAreRateLimited(t *testing.T) {
    -	rateMu.Lock()
    -	rateBuckets = map[string][]time.Time{}
    -	rateMu.Unlock()
    +func TestRateLimitMiddleware_RateLimitsHealthAndMetrics(t *testing.T) {
    +	resetRateLimitStateForTests()
    +	t.Cleanup(resetRateLimitStateForTests)
     
     	handler := RateLimitMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
     		w.WriteHeader(200)
     	}))
    -
    -	for _, path := range []string{"/health", "/metrics"} {
    -		rateMu.Lock()
    -		rateBuckets = map[string][]time.Time{}
    -		rateMu.Unlock()
    -
    +	for _, p := range []string{"/health", "/metrics"} {
    +		resetRateLimitStateForTests()
     		for i := 0; i < rateLimitMaxReq; i++ {
    -			req := httptest.NewRequest(http.MethodGet, path, nil)
    +			req := httptest.NewRequest("GET", p, nil)
     			req.RemoteAddr = "127.0.0.1:12345"
     			w := httptest.NewRecorder()
     			handler.ServeHTTP(w, req)
     			if w.Code != http.StatusOK {
    -				t.Fatalf("request %d for %s: expected 200, got %d", i+1, path, w.Code)
    +				t.Fatalf("%s request %d: expected 200, got %d", p, i+1, w.Code)
     			}
     		}
     
    -		req := httptest.NewRequest(http.MethodGet, path, nil)
    +		req := httptest.NewRequest("GET", p, nil)
     		req.RemoteAddr = "127.0.0.1:12345"
     		w := httptest.NewRecorder()
     		handler.ServeHTTP(w, req)
     		if w.Code != http.StatusTooManyRequests {
    -			t.Fatalf("expected 429 for %s after limit exceeded, got %d", path, w.Code)
    +			t.Fatalf("expected 429 for %s after limit exceeded, got %d", p, w.Code)
     		}
     	}
     }
     
    +func TestRateLimitMiddleware_IgnoresSpoofedForwardedHeaders(t *testing.T) {
    +	resetRateLimitStateForTests()
    +	t.Cleanup(resetRateLimitStateForTests)
    +
    +	now := time.Now()
    +	hits := make([]time.Time, rateLimitMaxReq)
    +	for i := range hits {
    +		hits[i] = now
    +	}
    +
    +	rateMu.Lock()
    +	rateBuckets["198.51.100.10"] = hits
    +	rateMu.Unlock()
    +
    +	handler := RateLimitMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +		w.WriteHeader(http.StatusOK)
    +	}))
    +
    +	req := httptest.NewRequest(http.MethodGet, "/test", nil)
    +	req.RemoteAddr = "198.51.100.10:41000"
    +	req.Header.Set("X-Forwarded-For", "203.0.113.1")
    +	req.Header.Set("X-Real-Ip", "203.0.113.2")
    +	w := httptest.NewRecorder()
    +	handler.ServeHTTP(w, req)
    +
    +	if w.Code != http.StatusTooManyRequests {
    +		t.Fatalf("expected 429 when forwarded headers spoof a new IP, got %d", w.Code)
    +	}
    +}
    +
     func TestEvictStaleRateBuckets_DeletesEmptyHosts(t *testing.T) {
    +	resetRateLimitStateForTests()
    +	t.Cleanup(resetRateLimitStateForTests)
    +
     	now := time.Now()
     	window := 10 * time.Second
     
    @@ -770,9 +803,6 @@ func TestEvictStaleRateBuckets_DeletesEmptyHosts(t *testing.T) {
     	if got := len(rateBuckets["fresh"]); got != 1 {
     		t.Fatalf("expected fresh bucket to keep 1 hit, got %d", got)
     	}
    -
    -	// Cleanup
    -	rateBuckets = map[string][]time.Time{}
     }
     
     func TestStatusWriter(t *testing.T) {
    
  • internal/httpx/httpx.go+40 2 modified
    @@ -4,10 +4,18 @@ import (
     	"bufio"
     	"context"
     	"encoding/json"
    +	"errors"
     	"fmt"
     	"log/slog"
     	"net"
     	"net/http"
    +
    +	"github.com/pinchtab/pinchtab/internal/sanitize"
    +)
    +
    +const (
    +	DefaultMaxJSONBodyBytes = 1 << 20
    +	maxErrorMessageBytes    = 1024
     )
     
     func JSON(w http.ResponseWriter, code int, data any) {
    @@ -19,12 +27,19 @@ func JSON(w http.ResponseWriter, code int, data any) {
     }
     
     func Error(w http.ResponseWriter, code int, err error) {
    -	ErrorCode(w, code, "error", err.Error(), false, nil)
    +	message := http.StatusText(code)
    +	if err != nil {
    +		message = err.Error()
    +	}
    +	if message == "" {
    +		message = "error"
    +	}
    +	ErrorCode(w, code, "error", message, false, nil)
     }
     
     func ErrorCode(w http.ResponseWriter, status int, code, message string, retryable bool, details map[string]any) {
     	payload := map[string]any{
    -		"error": message,
    +		"error": SanitizeErrorMessage(message),
     		"code":  code,
     	}
     	if retryable {
    @@ -36,6 +51,29 @@ func ErrorCode(w http.ResponseWriter, status int, code, message string, retryabl
     	JSON(w, status, payload)
     }
     
    +func DecodeJSONBody(w http.ResponseWriter, r *http.Request, maxBytes int64, dst any) error {
    +	if maxBytes <= 0 {
    +		maxBytes = DefaultMaxJSONBodyBytes
    +	}
    +	return json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBytes)).Decode(dst)
    +}
    +
    +func StatusForJSONDecodeError(err error) int {
    +	var maxErr *http.MaxBytesError
    +	if errors.As(err, &maxErr) {
    +		return http.StatusRequestEntityTooLarge
    +	}
    +	return http.StatusBadRequest
    +}
    +
    +func SanitizeErrorMessage(message string) string {
    +	message = sanitize.CleanError(message, maxErrorMessageBytes)
    +	if message == "" {
    +		return "error"
    +	}
    +	return message
    +}
    +
     // CancelOnClientDone cancels the given cancel func when the HTTP client disconnects.
     func CancelOnClientDone(reqCtx context.Context, cancel context.CancelFunc) {
     	<-reqCtx.Done()
    
  • internal/httpx/httpx_test.go+32 0 modified
    @@ -4,6 +4,7 @@ import (
     	"fmt"
     	"net/http"
     	"net/http/httptest"
    +	"strings"
     	"testing"
     )
     
    @@ -58,3 +59,34 @@ func TestError(t *testing.T) {
     		t.Errorf("expected body %q, got %q", expectedBody, w.Body.String())
     	}
     }
    +
    +func TestError_SanitizesMessage(t *testing.T) {
    +	w := httptest.NewRecorder()
    +	err := fmt.Errorf("bad \x1b[31mrequest \x00 at /Users/tester/private.txt")
    +	Error(w, http.StatusBadRequest, err)
    +
    +	body := w.Body.String()
    +	if body == "" {
    +		t.Fatal("expected response body")
    +	}
    +	if got := w.Code; got != http.StatusBadRequest {
    +		t.Fatalf("status = %d, want %d", got, http.StatusBadRequest)
    +	}
    +	if strings.Contains(body, "\x1b") || strings.Contains(body, "\x00") || strings.Contains(body, "/Users/tester") {
    +		t.Fatalf("expected sanitized body, got %q", body)
    +	}
    +	if !strings.Contains(body, "[path]") {
    +		t.Fatalf("expected redacted path marker in %q", body)
    +	}
    +}
    +
    +func TestStatusForJSONDecodeError(t *testing.T) {
    +	if got := StatusForJSONDecodeError(fmt.Errorf("bad json")); got != http.StatusBadRequest {
    +		t.Fatalf("StatusForJSONDecodeError(bad json) = %d, want %d", got, http.StatusBadRequest)
    +	}
    +
    +	err := &http.MaxBytesError{Limit: 1}
    +	if got := StatusForJSONDecodeError(err); got != http.StatusRequestEntityTooLarge {
    +		t.Fatalf("StatusForJSONDecodeError(max bytes) = %d, want %d", got, http.StatusRequestEntityTooLarge)
    +	}
    +}
    
  • internal/instance/bridge_client.go+6 5 modified
    @@ -10,6 +10,7 @@ import (
     	"time"
     
     	"github.com/pinchtab/pinchtab/internal/bridge"
    +	"github.com/pinchtab/pinchtab/internal/httpx"
     )
     
     // BridgeClient makes HTTP calls to a bridge instance.
    @@ -161,21 +162,21 @@ func (bc *BridgeClient) ProxyWithTabID(w http.ResponseWriter, r *http.Request, p
     
     	encoded, err := json.Marshal(body)
     	if err != nil {
    -		http.Error(w, fmt.Sprintf("encode body: %s", err), http.StatusInternalServerError)
    +		httpx.Error(w, http.StatusInternalServerError, fmt.Errorf("encode body: %w", err))
     		return
     	}
     
     	targetURL := bridgeURL(port, path)
     	proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, strings.NewReader(string(encoded)))
     	if err != nil {
    -		http.Error(w, fmt.Sprintf("proxy request: %s", err), http.StatusInternalServerError)
    +		httpx.Error(w, http.StatusInternalServerError, fmt.Errorf("proxy request: %w", err))
     		return
     	}
     	proxyReq.Header.Set("Content-Type", "application/json")
     
     	resp, err := bc.client.Do(proxyReq)
     	if err != nil {
    -		http.Error(w, fmt.Sprintf("proxy failed: %s", err), http.StatusBadGateway)
    +		httpx.Error(w, http.StatusBadGateway, fmt.Errorf("proxy failed: %w", err))
     		return
     	}
     	defer func() { _ = resp.Body.Close() }()
    @@ -200,7 +201,7 @@ func (bc *BridgeClient) ProxyToTab(w http.ResponseWriter, r *http.Request, port,
     
     	proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body)
     	if err != nil {
    -		http.Error(w, fmt.Sprintf("proxy request: %s", err), http.StatusInternalServerError)
    +		httpx.Error(w, http.StatusInternalServerError, fmt.Errorf("proxy request: %w", err))
     		return
     	}
     
    @@ -217,7 +218,7 @@ func (bc *BridgeClient) ProxyToTab(w http.ResponseWriter, r *http.Request, port,
     
     	resp, err := bc.client.Do(proxyReq)
     	if err != nil {
    -		http.Error(w, fmt.Sprintf("proxy failed: %s", err), http.StatusBadGateway)
    +		httpx.Error(w, http.StatusBadGateway, fmt.Errorf("proxy failed: %w", err))
     		return
     	}
     	defer func() { _ = resp.Body.Close() }()
    
  • internal/orchestrator/handlers_instances.go+37 55 modified
    @@ -12,6 +12,13 @@ import (
     	"github.com/pinchtab/pinchtab/internal/httpx"
     )
     
    +type startInstanceRequest struct {
    +	ProfileID      string   `json:"profileId,omitempty"`
    +	Mode           string   `json:"mode,omitempty"`
    +	Port           string   `json:"port,omitempty"`
    +	ExtensionPaths []string `json:"extensionPaths,omitempty"`
    +}
    +
     func (o *Orchestrator) handleGetInstance(w http.ResponseWriter, r *http.Request) {
     	id := r.PathValue("id")
     	o.mu.RLock()
    @@ -39,55 +46,23 @@ func (o *Orchestrator) handleGetInstance(w http.ResponseWriter, r *http.Request)
     
     func (o *Orchestrator) handleLaunchByName(w http.ResponseWriter, r *http.Request) {
     	var req struct {
    -		ProfileId      string   `json:"profileId,omitempty"`
    -		Name           string   `json:"name,omitempty"`
    -		Mode           string   `json:"mode"`
    -		Port           string   `json:"port,omitempty"`
    -		ExtensionPaths []string `json:"extensionPaths,omitempty"`
    +		startInstanceRequest
    +		Name string `json:"name,omitempty"`
     	}
     
     	if r.ContentLength > 0 {
    -		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    -			httpx.Error(w, 400, fmt.Errorf("invalid JSON"))
    -			return
    -		}
    -	}
    -
    -	headless := req.Mode != "headed"
    -
    -	var name string
    -	if req.ProfileId != "" {
    -		profs, err := o.profiles.List()
    -		if err != nil {
    -			httpx.Error(w, 500, fmt.Errorf("failed to list profiles: %w", err))
    -			return
    -		}
    -		found := false
    -		for _, p := range profs {
    -			if p.ID == req.ProfileId {
    -				name = p.Name
    -				found = true
    -				break
    -			}
    -		}
    -		if !found {
    -			httpx.Error(w, 400, fmt.Errorf("profile %q not found", req.ProfileId))
    +		if err := httpx.DecodeJSONBody(w, r, 0, &req); err != nil {
    +			httpx.Error(w, httpx.StatusForJSONDecodeError(err), fmt.Errorf("invalid JSON"))
     			return
     		}
    -	} else if req.Name != "" {
    -		name = req.Name
    -	} else {
    -		name = fmt.Sprintf("instance-%d", time.Now().UnixNano())
     	}
     
    -	inst, err := o.Launch(name, req.Port, headless, req.ExtensionPaths)
    -	if err != nil {
    -		statusCode := classifyLaunchError(err)
    -		httpx.Error(w, statusCode, err)
    +	if req.Name != "" {
    +		httpx.Error(w, 400, fmt.Errorf("name is not supported on /instances/launch; create the profile first via /profiles and then use profileId"))
     		return
     	}
    -	authn.AuditLog(r, "instance.launched", "profileName", name, "instanceId", inst.ID)
    -	httpx.JSON(w, 201, inst)
    +
    +	o.startInstanceWithRequest(w, r, req.startInstanceRequest, "instance.launched")
     }
     
     func (o *Orchestrator) handleStopByInstanceID(w http.ResponseWriter, r *http.Request) {
    @@ -226,20 +201,19 @@ func (o *Orchestrator) handleLogsStreamByID(w http.ResponseWriter, r *http.Reque
     }
     
     func (o *Orchestrator) handleStartInstance(w http.ResponseWriter, r *http.Request) {
    -	var req struct {
    -		ProfileID      string   `json:"profileId,omitempty"`
    -		Mode           string   `json:"mode,omitempty"`
    -		Port           string   `json:"port,omitempty"`
    -		ExtensionPaths []string `json:"extensionPaths,omitempty"`
    -	}
    +	var req startInstanceRequest
     
     	if r.ContentLength > 0 {
    -		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    -			httpx.Error(w, 400, fmt.Errorf("invalid JSON"))
    +		if err := httpx.DecodeJSONBody(w, r, 0, &req); err != nil {
    +			httpx.Error(w, httpx.StatusForJSONDecodeError(err), fmt.Errorf("invalid JSON"))
     			return
     		}
     	}
     
    +	o.startInstanceWithRequest(w, r, req, "instance.started")
    +}
    +
    +func (o *Orchestrator) startInstanceWithRequest(w http.ResponseWriter, r *http.Request, req startInstanceRequest, auditEvent string) {
     	var profileName string
     	var err error
     
    @@ -262,7 +236,7 @@ func (o *Orchestrator) handleStartInstance(w http.ResponseWriter, r *http.Reques
     		return
     	}
     
    -	authn.AuditLog(r, "instance.started", "instanceId", inst.ID, "profileName", profileName)
    +	authn.AuditLog(r, auditEvent, "instanceId", inst.ID, "profileName", profileName)
     	httpx.JSON(w, 201, inst)
     }
     
    @@ -308,8 +282,8 @@ func (o *Orchestrator) handleAttachInstance(w http.ResponseWriter, r *http.Reque
     		Name   string `json:"name,omitempty"`
     	}
     
    -	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    -		httpx.Error(w, 400, fmt.Errorf("invalid JSON"))
    +	if err := httpx.DecodeJSONBody(w, r, 0, &req); err != nil {
    +		httpx.Error(w, httpx.StatusForJSONDecodeError(err), fmt.Errorf("invalid JSON"))
     		return
     	}
     
    @@ -347,8 +321,8 @@ func (o *Orchestrator) handleAttachBridge(w http.ResponseWriter, r *http.Request
     		Token   string `json:"token,omitempty"`
     	}
     
    -	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    -		httpx.Error(w, 400, fmt.Errorf("invalid JSON"))
    +	if err := httpx.DecodeJSONBody(w, r, 0, &req); err != nil {
    +		httpx.Error(w, httpx.StatusForJSONDecodeError(err), fmt.Errorf("invalid JSON"))
     		return
     	}
     	if req.BaseURL == "" {
    @@ -443,8 +417,16 @@ func (o *Orchestrator) validateAttachURL(rawURL string) error {
     		return fmt.Errorf("scheme %q not allowed (allowed: %v)", parsed.Scheme, o.runtimeCfg.AttachAllowSchemes)
     	}
     
    -	if (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Path != "" && parsed.Path != "/" {
    -		return fmt.Errorf("bridge baseUrl must not include a path")
    +	if parsed.Scheme == "http" || parsed.Scheme == "https" {
    +		if parsed.Path != "" && parsed.Path != "/" {
    +			return fmt.Errorf("bridge baseUrl must not include a path")
    +		}
    +		if parsed.User != nil {
    +			return fmt.Errorf("bridge baseUrl must not include userinfo")
    +		}
    +		if parsed.RawQuery != "" || parsed.Fragment != "" {
    +			return fmt.Errorf("bridge baseUrl must not include query or fragment")
    +		}
     	}
     
     	// Validate host
    
  • internal/orchestrator/handlers_instances_test.go+66 0 added
    @@ -0,0 +1,66 @@
    +package orchestrator
    +
    +import (
    +	"encoding/json"
    +	"net/http"
    +	"net/http/httptest"
    +	"strings"
    +	"testing"
    +
    +	"github.com/pinchtab/pinchtab/internal/bridge"
    +	"github.com/pinchtab/pinchtab/internal/profiles"
    +)
    +
    +func TestHandleLaunchByNameRejectsNameField(t *testing.T) {
    +	o := NewOrchestratorWithRunner(t.TempDir(), &mockRunner{portAvail: true})
    +
    +	req := httptest.NewRequest(http.MethodPost, "/instances/launch", strings.NewReader(`{"name":"work","mode":"headed"}`))
    +	w := httptest.NewRecorder()
    +
    +	o.handleLaunchByName(w, req)
    +
    +	if w.Code != http.StatusBadRequest {
    +		t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest)
    +	}
    +	if !strings.Contains(w.Body.String(), "name is not supported on /instances/launch") {
    +		t.Fatalf("body = %q, want unsupported-name message", w.Body.String())
    +	}
    +}
    +
    +func TestHandleLaunchByNameAliasesStartSemantics(t *testing.T) {
    +	old := processAliveFunc
    +	processAliveFunc = func(pid int) bool { return pid > 0 }
    +	defer func() { processAliveFunc = old }()
    +
    +	baseDir := t.TempDir()
    +	runner := &mockRunner{portAvail: true}
    +	o := NewOrchestratorWithRunner(baseDir, runner)
    +	pm := profiles.NewProfileManager(baseDir)
    +	if err := pm.CreateWithMeta("work", profiles.ProfileMeta{}); err != nil {
    +		t.Fatalf("CreateWithMeta: %v", err)
    +	}
    +	o.profiles = pm
    +
    +	req := httptest.NewRequest(http.MethodPost, "/instances/launch", strings.NewReader(`{"profileId":"work","mode":"headed"}`))
    +	w := httptest.NewRecorder()
    +
    +	o.handleLaunchByName(w, req)
    +
    +	if w.Code != http.StatusCreated {
    +		t.Fatalf("status = %d, want %d body=%s", w.Code, http.StatusCreated, w.Body.String())
    +	}
    +	if !runner.runCalled {
    +		t.Fatal("expected instance launch to invoke the runner")
    +	}
    +
    +	var inst bridge.Instance
    +	if err := json.NewDecoder(w.Body).Decode(&inst); err != nil {
    +		t.Fatalf("decode response: %v", err)
    +	}
    +	if inst.ProfileName != "work" {
    +		t.Fatalf("ProfileName = %q, want %q", inst.ProfileName, "work")
    +	}
    +	if inst.Headless {
    +		t.Fatal("Headless = true, want false for mode=headed")
    +	}
    +}
    
  • internal/orchestrator/handlers_profiles.go+6 2 modified
    @@ -1,7 +1,6 @@
     package orchestrator
     
     import (
    -	"encoding/json"
     	"fmt"
     	"net/http"
     
    @@ -34,7 +33,12 @@ func (o *Orchestrator) handleStartByID(w http.ResponseWriter, r *http.Request) {
     		Port     string `json:"port,omitempty"`
     		Headless bool   `json:"headless"`
     	}
    -	_ = json.NewDecoder(r.Body).Decode(&req)
    +	if r.ContentLength > 0 {
    +		if err := httpx.DecodeJSONBody(w, r, 0, &req); err != nil {
    +			httpx.Error(w, httpx.StatusForJSONDecodeError(err), fmt.Errorf("invalid JSON"))
    +			return
    +		}
    +	}
     
     	inst, err := o.Launch(name, req.Port, req.Headless, nil)
     	if err != nil {
    
  • internal/orchestrator/handlers_tabs.go+2 2 modified
    @@ -80,8 +80,8 @@ func (o *Orchestrator) handleInstanceTabOpen(w http.ResponseWriter, r *http.Requ
     		URL string `json:"url,omitempty"`
     	}
     	if r.ContentLength > 0 {
    -		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    -			httpx.Error(w, 400, fmt.Errorf("invalid JSON"))
    +		if err := httpx.DecodeJSONBody(w, r, 0, &req); err != nil {
    +			httpx.Error(w, httpx.StatusForJSONDecodeError(err), fmt.Errorf("invalid JSON"))
     			return
     		}
     	}
    
  • internal/orchestrator/orchestrator.go+6 1 modified
    @@ -472,10 +472,15 @@ func (o *Orchestrator) Attach(name, cdpURL string) (*bridge.Instance, error) {
     
     // AttachBridge registers an already-running bridge server as an attached instance.
     func (o *Orchestrator) AttachBridge(name, baseURL, token string) (*bridge.Instance, error) {
    +	normalizedBaseURL := strings.TrimRight(baseURL, "/")
    +	if parsed, err := url.Parse(normalizedBaseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" {
    +		normalizedBaseURL = parsed.Scheme + "://" + parsed.Host
    +	}
    +
     	inst, err := o.attachExternalInstance(name, bridge.Instance{
     		Attached:   true,
     		AttachType: "bridge",
    -		URL:        strings.TrimRight(baseURL, "/"),
    +		URL:        normalizedBaseURL,
     	}, token)
     	if err != nil {
     		return nil, err
    
  • internal/orchestrator/orchestrator_test.go+55 0 modified
    @@ -148,6 +148,8 @@ func TestOrchestrator_Launch_RejectsPathTraversal(t *testing.T) {
     		{"backslash", "test\\nested", "cannot contain '/'"},
     		{"empty name", "", "cannot be empty"},
     		{"absolute path attempt", "../../../etc/passwd", "cannot contain"},
    +		{"powershell metacharacter", "poc';calc", "contains invalid character"},
    +		{"reserved windows device name", "CON", "reserved device name"},
     	}
     
     	for _, tt := range badNames {
    @@ -178,6 +180,7 @@ func TestOrchestrator_Launch_AcceptsValidNames(t *testing.T) {
     		"with-dash",
     		"with_underscore",
     		"with.dot",
    +		"Work Profile",
     		"CamelCase",
     		"123numeric",
     		"a",
    @@ -436,6 +439,58 @@ func TestValidateAttachURL_RejectsBridgeBaseURLWithPath(t *testing.T) {
     	}
     }
     
    +func TestValidateAttachURL_RejectsBridgeBaseURLWithUserinfo(t *testing.T) {
    +	o := NewOrchestratorWithRunner(t.TempDir(), &mockRunner{portAvail: true})
    +	o.ApplyRuntimeConfig(&config.RuntimeConfig{
    +		AttachEnabled:      true,
    +		AttachAllowHosts:   []string{"10.0.0.8"},
    +		AttachAllowSchemes: []string{"http"},
    +	})
    +
    +	err := o.validateAttachURL("http://user:pass@10.0.0.8:9868")
    +	if err == nil {
    +		t.Fatal("expected error for attach bridge URL with userinfo")
    +	}
    +	if !strings.Contains(err.Error(), "must not include userinfo") {
    +		t.Fatalf("unexpected error: %v", err)
    +	}
    +}
    +
    +func TestValidateAttachURL_RejectsBridgeBaseURLWithQueryOrFragment(t *testing.T) {
    +	o := NewOrchestratorWithRunner(t.TempDir(), &mockRunner{portAvail: true})
    +	o.ApplyRuntimeConfig(&config.RuntimeConfig{
    +		AttachEnabled:      true,
    +		AttachAllowHosts:   []string{"10.0.0.8"},
    +		AttachAllowSchemes: []string{"http"},
    +	})
    +
    +	for _, raw := range []string{
    +		"http://10.0.0.8:9868?token=secret",
    +		"http://10.0.0.8:9868#debug",
    +	} {
    +		err := o.validateAttachURL(raw)
    +		if err == nil {
    +			t.Fatalf("expected error for attach bridge URL %q", raw)
    +		}
    +		if !strings.Contains(err.Error(), "must not include query or fragment") {
    +			t.Fatalf("unexpected error for %q: %v", raw, err)
    +		}
    +	}
    +}
    +
    +func TestOrchestrator_AttachBridge_NormalizesBaseURL(t *testing.T) {
    +	runner := &mockRunner{portAvail: true}
    +	o := NewOrchestratorWithRunner(t.TempDir(), runner)
    +
    +	inst, err := o.AttachBridge("bridge1", "http://10.0.0.8:9868/?debug=1#frag", "bridge-token")
    +	if err != nil {
    +		t.Fatalf("AttachBridge failed: %v", err)
    +	}
    +	if inst.URL != "http://10.0.0.8:9868" {
    +		t.Fatalf("URL = %q, want %q", inst.URL, "http://10.0.0.8:9868")
    +	}
    +}
    +
     func TestValidateAttachURL_WildcardHost(t *testing.T) {
     	o := NewOrchestratorWithRunner(t.TempDir(), &mockRunner{portAvail: true})
     	o.ApplyRuntimeConfig(&config.RuntimeConfig{
    
  • internal/orchestrator/proxy.go+6 0 modified
    @@ -246,6 +246,12 @@ func (o *Orchestrator) parseHTTPInstanceURL(rawURL, port string) (*url.URL, erro
     	if parsed.Path != "" && parsed.Path != "/" {
     		return nil, fmt.Errorf("instance URL %q must not include a path", rawURL)
     	}
    +	if parsed.User != nil {
    +		return nil, fmt.Errorf("instance URL %q must not include userinfo", rawURL)
    +	}
    +	if parsed.RawQuery != "" || parsed.Fragment != "" {
    +		return nil, fmt.Errorf("instance URL %q must not include query or fragment", rawURL)
    +	}
     	return parsed, nil
     }
     
    
  • internal/profiles/handlers.go+26 27 modified
    @@ -1,7 +1,6 @@
     package profiles
     
     import (
    -	"encoding/json"
     	"fmt"
     	"net/http"
     	"strconv"
    @@ -11,6 +10,19 @@ import (
     	"github.com/pinchtab/pinchtab/internal/httpx"
     )
     
    +func profileMutationStatus(err error) int {
    +	switch {
    +	case err == nil:
    +		return http.StatusOK
    +	case isProfileNameValidationError(err):
    +		return http.StatusBadRequest
    +	case strings.Contains(err.Error(), "already exists"):
    +		return http.StatusConflict
    +	default:
    +		return http.StatusInternalServerError
    +	}
    +}
    +
     func (pm *ProfileManager) RegisterHandlers(mux *http.ServeMux) {
     	mux.HandleFunc("GET /profiles", pm.handleList)
     	mux.HandleFunc("POST /profiles", pm.handleCreate)
    @@ -72,8 +84,8 @@ func (pm *ProfileManager) handleCreate(w http.ResponseWriter, r *http.Request) {
     		Description string `json:"description"`
     		UseWhen     string `json:"useWhen"`
     	}
    -	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    -		httpx.Error(w, 400, err)
    +	if err := httpx.DecodeJSONBody(w, r, 0, &req); err != nil {
    +		httpx.Error(w, httpx.StatusForJSONDecodeError(err), err)
     		return
     	}
     	if req.Name == "" {
    @@ -87,14 +99,7 @@ func (pm *ProfileManager) handleCreate(w http.ResponseWriter, r *http.Request) {
     	}
     
     	if err := pm.CreateWithMeta(req.Name, meta); err != nil {
    -		// Validation errors → 400, already exists → 409, others → 500
    -		if strings.Contains(err.Error(), "cannot contain") || strings.Contains(err.Error(), "cannot be empty") {
    -			httpx.Error(w, 400, err)
    -		} else if strings.Contains(err.Error(), "already exists") {
    -			httpx.Error(w, 409, err)
    -		} else {
    -			httpx.Error(w, 500, err)
    -		}
    +		httpx.Error(w, profileMutationStatus(err), err)
     		return
     	}
     
    @@ -114,8 +119,8 @@ func (pm *ProfileManager) handleImport(w http.ResponseWriter, r *http.Request) {
     		Description string `json:"description"`
     		UseWhen     string `json:"useWhen"`
     	}
    -	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    -		httpx.Error(w, 400, err)
    +	if err := httpx.DecodeJSONBody(w, r, 0, &req); err != nil {
    +		httpx.Error(w, httpx.StatusForJSONDecodeError(err), err)
     		return
     	}
     	if req.Name == "" || req.SourcePath == "" {
    @@ -129,7 +134,7 @@ func (pm *ProfileManager) handleImport(w http.ResponseWriter, r *http.Request) {
     	}
     
     	if err := pm.ImportWithMeta(req.Name, req.SourcePath, meta); err != nil {
    -		httpx.Error(w, 500, err)
    +		httpx.Error(w, profileMutationStatus(err), err)
     		return
     	}
     	authn.AuditLog(r, "profile.imported", "profileName", req.Name)
    @@ -142,8 +147,8 @@ func (pm *ProfileManager) handleUpdateMeta(w http.ResponseWriter, r *http.Reques
     		Description *string `json:"description"`
     		UseWhen     *string `json:"useWhen"`
     	}
    -	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    -		httpx.Error(w, 400, err)
    +	if err := httpx.DecodeJSONBody(w, r, 0, &req); err != nil {
    +		httpx.Error(w, httpx.StatusForJSONDecodeError(err), err)
     		return
     	}
     	if req.Name == "" {
    @@ -160,7 +165,7 @@ func (pm *ProfileManager) handleUpdateMeta(w http.ResponseWriter, r *http.Reques
     	}
     
     	if err := pm.UpdateMeta(req.Name, updates); err != nil {
    -		httpx.Error(w, 500, err)
    +		httpx.Error(w, profileMutationStatus(err), err)
     		return
     	}
     	authn.AuditLog(r, "profile.meta_updated", "profileName", req.Name)
    @@ -259,21 +264,15 @@ func (pm *ProfileManager) handleUpdateByID(w http.ResponseWriter, r *http.Reques
     		UseWhen     *string `json:"useWhen"`
     		Description *string `json:"description"`
     	}
    -	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    -		httpx.Error(w, 400, fmt.Errorf("invalid JSON"))
    +	if err := httpx.DecodeJSONBody(w, r, 0, &req); err != nil {
    +		httpx.Error(w, httpx.StatusForJSONDecodeError(err), fmt.Errorf("invalid JSON"))
     		return
     	}
     
     	finalName := name
     	if req.Name != nil && *req.Name != name {
     		if err := pm.Rename(name, *req.Name); err != nil {
    -			if strings.Contains(err.Error(), "already exists") {
    -				httpx.Error(w, 409, err)
    -			} else if strings.Contains(err.Error(), "cannot contain") || strings.Contains(err.Error(), "cannot be empty") {
    -				httpx.Error(w, 400, err)
    -			} else {
    -				httpx.Error(w, 500, err)
    -			}
    +			httpx.Error(w, profileMutationStatus(err), err)
     			return
     		}
     		finalName = *req.Name
    @@ -288,7 +287,7 @@ func (pm *ProfileManager) handleUpdateByID(w http.ResponseWriter, r *http.Reques
     	}
     	if len(updates) > 0 {
     		if err := pm.UpdateMeta(finalName, updates); err != nil {
    -			httpx.Error(w, 500, err)
    +			httpx.Error(w, profileMutationStatus(err), err)
     			return
     		}
     	}
    
  • internal/profiles/profiles.go+61 5 modified
    @@ -10,32 +10,85 @@ import (
     	"strings"
     	"sync"
     	"time"
    +	"unicode"
     
     	"github.com/pinchtab/pinchtab/internal/bridge"
     	"github.com/pinchtab/pinchtab/internal/ids"
     )
     
     var idMgr = ids.NewManager()
     
    +var reservedWindowsProfileNames = map[string]struct{}{
    +	"CON":  {},
    +	"PRN":  {},
    +	"AUX":  {},
    +	"NUL":  {},
    +	"COM1": {},
    +	"COM2": {},
    +	"COM3": {},
    +	"COM4": {},
    +	"COM5": {},
    +	"COM6": {},
    +	"COM7": {},
    +	"COM8": {},
    +	"COM9": {},
    +	"LPT1": {},
    +	"LPT2": {},
    +	"LPT3": {},
    +	"LPT4": {},
    +	"LPT5": {},
    +	"LPT6": {},
    +	"LPT7": {},
    +	"LPT8": {},
    +	"LPT9": {},
    +}
    +
     func profileID(name string) string {
     	return idMgr.ProfileID(name)
     }
     
    -// ValidateProfileName checks that a profile name is safe and doesn't contain
    -// path traversal characters like "..", "/", or "\".
    +// ValidateProfileName enforces a cross-platform-safe profile name policy for
    +// filesystem usage and shell-adjacent process cleanup on Windows.
     func ValidateProfileName(name string) error {
     	if name == "" {
     		return fmt.Errorf("profile name cannot be empty")
     	}
    +	trimmed := strings.TrimSpace(name)
    +	if trimmed == "" {
    +		return fmt.Errorf("profile name cannot be blank")
    +	}
    +	if trimmed != name {
    +		return fmt.Errorf("profile name cannot start or end with whitespace")
    +	}
     	if strings.Contains(name, "..") {
     		return fmt.Errorf("profile name cannot contain '..'")
     	}
     	if strings.ContainsAny(name, "/\\") {
     		return fmt.Errorf("profile name cannot contain '/' or '\\'")
     	}
    +	if strings.HasSuffix(name, ".") {
    +		return fmt.Errorf("profile name cannot end with '.'")
    +	}
    +	for _, r := range name {
    +		if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' || r == '-' || r == '_' || r == '.' {
    +			continue
    +		}
    +		return fmt.Errorf("profile name contains invalid character %q", r)
    +	}
    +	base := name
    +	if dot := strings.IndexRune(base, '.'); dot >= 0 {
    +		base = base[:dot]
    +	}
    +	if _, reserved := reservedWindowsProfileNames[strings.ToUpper(base)]; reserved {
    +		return fmt.Errorf("profile name cannot use reserved device name %q", base)
    +	}
     	return nil
     }
     
    +func isProfileNameValidationError(err error) bool {
    +	return err != nil && strings.HasPrefix(err.Error(), "profile name ")
    +}
    +
     type ProfileManager struct {
     	baseDir string
     	tracker *ActionTracker
    @@ -379,6 +432,12 @@ func (pm *ProfileManager) Reset(name string) error {
     		return err
     	}
     
    +	resetProfileDir(dir)
    +	slog.Info("profile reset", "name", name)
    +	return nil
    +}
    +
    +func resetProfileDir(dir string) {
     	nukeDirs := []string{
     		"Default/Sessions",
     		"Default/Session Storage",
    @@ -408,9 +467,6 @@ func (pm *ProfileManager) Reset(name string) error {
     	for _, f := range nukeFiles {
     		_ = os.Remove(filepath.Join(dir, f))
     	}
    -
    -	slog.Info("profile reset", "name", name)
    -	return nil
     }
     
     func (pm *ProfileManager) Delete(name string) error {
    
  • internal/profiles/profiles_test.go+79 0 modified
    @@ -459,6 +459,21 @@ func TestProfileUpdateMeta(t *testing.T) {
     	}
     }
     
    +func TestProfileUpdateMetaRejectsInvalidProfileName(t *testing.T) {
    +	pm := NewProfileManager(t.TempDir())
    +	mux := http.NewServeMux()
    +	pm.RegisterHandlers(mux)
    +
    +	req := httptest.NewRequest(http.MethodPatch, "/profiles/meta", strings.NewReader(`{"name":"poc';calc","description":"x"}`))
    +	req.Header.Set("Content-Type", "application/json")
    +	w := httptest.NewRecorder()
    +	mux.ServeHTTP(w, req)
    +
    +	if w.Code != http.StatusBadRequest {
    +		t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
    +	}
    +}
    +
     func TestProfileUpdateByIDCanClearMetadata(t *testing.T) {
     	pm := NewProfileManager(t.TempDir())
     	mux := http.NewServeMux()
    @@ -496,6 +511,25 @@ func TestProfileUpdateByIDCanClearMetadata(t *testing.T) {
     	}
     }
     
    +func TestProfileUpdateByIDRejectsInvalidRename(t *testing.T) {
    +	pm := NewProfileManager(t.TempDir())
    +	mux := http.NewServeMux()
    +	pm.RegisterHandlers(mux)
    +
    +	if err := pm.Create("renameable"); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	req := httptest.NewRequest(http.MethodPatch, "/profiles/"+profileID("renameable"), strings.NewReader(`{"name":"poc';calc"}`))
    +	req.Header.Set("Content-Type", "application/json")
    +	w := httptest.NewRecorder()
    +	mux.ServeHTTP(w, req)
    +
    +	if w.Code != http.StatusBadRequest {
    +		t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
    +	}
    +}
    +
     func TestProfileCreateWithUseWhen(t *testing.T) {
     	pm := NewProfileManager(t.TempDir())
     	mux := http.NewServeMux()
    @@ -555,6 +589,7 @@ func TestValidateProfileName(t *testing.T) {
     		{"valid with numbers", "profile123", false, ""},
     		{"valid with underscore", "my_profile", false, ""},
     		{"valid with dots", "my.profile", false, ""},
    +		{"valid with spaces", "Work Profile", false, ""},
     		{"valid single char", "a", false, ""},
     
     		// Empty name
    @@ -574,6 +609,18 @@ func TestValidateProfileName(t *testing.T) {
     		{"forward slash suffix", "test/", true, "cannot contain '/'"},
     		{"backslash", "test\\profile", true, "cannot contain '/'"},
     		{"backslash prefix", "\\test", true, "cannot contain '/'"},
    +		{"single quote", "poc';calc", true, "contains invalid character"},
    +		{"semicolon", "poc;calc", true, "contains invalid character"},
    +		{"pipe", "poc|calc", true, "contains invalid character"},
    +		{"dollar", "poc$calc", true, "contains invalid character"},
    +		{"backtick", "poc`calc", true, "contains invalid character"},
    +		{"colon", "poc:calc", true, "contains invalid character"},
    +		{"trailing dot", "poc.", true, "cannot end with '.'"},
    +		{"leading whitespace", " profile", true, "cannot start or end with whitespace"},
    +		{"trailing whitespace", "profile ", true, "cannot start or end with whitespace"},
    +		{"reserved device name", "CON", true, "reserved device name"},
    +		{"reserved device name with extension", "con.txt", true, "reserved device name"},
    +		{"reserved printer name", "LPT1", true, "reserved device name"},
     
     		// Combined attacks
     		{"traversal with slash", "../../../etc/passwd", true, "cannot contain"},
    @@ -608,6 +655,10 @@ func TestProfileCreateRejectsPathTraversal(t *testing.T) {
     		"../../etc/passwd",
     		"test/subdir",
     		"/absolute",
    +		"poc';calc",
    +		"bad|name",
    +		"CON",
    +		"con.txt",
     	}
     
     	for _, name := range badNames {
    @@ -633,6 +684,10 @@ func TestProfileHandlerCreateRejectsPathTraversal(t *testing.T) {
     		{"path traversal ..", `{"name":"../malicious"}`, 400},
     		{"path traversal /", `{"name":"test/nested"}`, 400},
     		{"path traversal backslash", `{"name":"test\\nested"}`, 400},
    +		{"powershell metacharacter", `{"name":"poc';calc"}`, 400},
    +		{"reserved device name", `{"name":"CON"}`, 400},
    +		{"trailing dot", `{"name":"bad."}`, 400},
    +		{"leading whitespace", `{"name":" bad"}`, 400},
     		{"empty name", `{"name":""}`, 400},
     		{"valid name", `{"name":"valid-profile"}`, 200},
     	}
    @@ -652,6 +707,30 @@ func TestProfileHandlerCreateRejectsPathTraversal(t *testing.T) {
     	}
     }
     
    +func TestProfileHandlerImportRejectsInvalidProfileName(t *testing.T) {
    +	pm := NewProfileManager(t.TempDir())
    +	mux := http.NewServeMux()
    +	pm.RegisterHandlers(mux)
    +
    +	src := filepath.Join(t.TempDir(), "chrome-src")
    +	if err := os.MkdirAll(filepath.Join(src, "Default"), 0755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.WriteFile(filepath.Join(src, "Default", "Preferences"), []byte(`{}`), 0644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	body := fmt.Sprintf(`{"name":"poc';calc","sourcePath":%q}`, src)
    +	req := httptest.NewRequest(http.MethodPost, "/profiles/import", strings.NewReader(body))
    +	req.Header.Set("Content-Type", "application/json")
    +	w := httptest.NewRecorder()
    +	mux.ServeHTTP(w, req)
    +
    +	if w.Code != http.StatusBadRequest {
    +		t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
    +	}
    +}
    +
     func TestProfileHandlerCreateReturns409OnDuplicate(t *testing.T) {
     	pm := NewProfileManager(t.TempDir())
     	mux := http.NewServeMux()
    
  • internal/proxy/proxy.go+26 1 modified
    @@ -39,6 +39,16 @@ var hopByHopHeaders = map[string]struct{}{
     	"host":                {},
     }
     
    +var strippedProxyRequestHeaders = map[string]struct{}{
    +	"cookie":            {},
    +	"forwarded":         {},
    +	"x-forwarded-for":   {},
    +	"x-forwarded-host":  {},
    +	"x-forwarded-proto": {},
    +	"x-real-ip":         {},
    +	"x-request-id":      {},
    +}
    +
     func Forward(w http.ResponseWriter, r *http.Request, targetURL *url.URL, opts Options) {
     	if targetURL == nil {
     		httpx.Error(w, 502, fmt.Errorf("proxy error: missing target URL"))
    @@ -73,7 +83,7 @@ func Forward(w http.ResponseWriter, r *http.Request, targetURL *url.URL, opts Op
     		httpx.Error(w, 502, fmt.Errorf("proxy error: %w", err))
     		return
     	}
    -	copyHeaders(outReq.Header, proxyReq.Header)
    +	copyRequestHeaders(outReq.Header, proxyReq.Header)
     
     	resp, err := client.Do(outReq)
     	if err != nil {
    @@ -134,3 +144,18 @@ func copyHeaders(dst, src http.Header) {
     		}
     	}
     }
    +
    +func copyRequestHeaders(dst, src http.Header) {
    +	for k, vv := range src {
    +		lower := strings.ToLower(k)
    +		if _, skip := hopByHopHeaders[lower]; skip {
    +			continue
    +		}
    +		if _, skip := strippedProxyRequestHeaders[lower]; skip {
    +			continue
    +		}
    +		for _, v := range vv {
    +			dst.Add(k, v)
    +		}
    +	}
    +}
    
  • internal/proxy/proxy_test.go+46 0 modified
    @@ -86,6 +86,52 @@ func TestHTTP_CopiesResponseHeaders(t *testing.T) {
     	}
     }
     
    +func TestHTTP_StripsSensitiveRequestHeaders(t *testing.T) {
    +	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +		_ = json.NewEncoder(w).Encode(map[string]any{
    +			"authorization":   r.Header.Get("Authorization"),
    +			"cookie":          r.Header.Get("Cookie"),
    +			"xForwardedFor":   r.Header.Get("X-Forwarded-For"),
    +			"xForwardedHost":  r.Header.Get("X-Forwarded-Host"),
    +			"xForwardedProto": r.Header.Get("X-Forwarded-Proto"),
    +			"forwarded":       r.Header.Get("Forwarded"),
    +			"xRealIP":         r.Header.Get("X-Real-Ip"),
    +			"xRequestID":      r.Header.Get("X-Request-Id"),
    +		})
    +	}))
    +	defer srv.Close()
    +
    +	req := httptest.NewRequest("GET", "/snapshot", nil)
    +	req.Header.Set("Authorization", "Bearer user-token")
    +	req.Header.Set("Cookie", "pinchtab_auth_token=session-secret")
    +	req.Header.Set("X-Forwarded-For", "203.0.113.10")
    +	req.Header.Set("X-Forwarded-Host", "app.example")
    +	req.Header.Set("X-Forwarded-Proto", "https")
    +	req.Header.Set("Forwarded", `for=203.0.113.10;host=app.example;proto=https`)
    +	req.Header.Set("X-Real-Ip", "203.0.113.10")
    +	req.Header.Set("X-Request-Id", "req-123")
    +
    +	rec := httptest.NewRecorder()
    +	HTTP(rec, req, srv.URL+"/snapshot")
    +
    +	if rec.Code != 200 {
    +		t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
    +	}
    +
    +	var resp map[string]any
    +	if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
    +		t.Fatalf("decode response: %v", err)
    +	}
    +	if resp["authorization"] != "Bearer user-token" {
    +		t.Fatalf("authorization = %v, want preserved bearer token", resp["authorization"])
    +	}
    +	for _, field := range []string{"cookie", "xForwardedFor", "xForwardedHost", "xForwardedProto", "forwarded", "xRealIP", "xRequestID"} {
    +		if got := resp[field]; got != "" {
    +			t.Fatalf("%s should have been stripped, got %v", field, got)
    +		}
    +	}
    +}
    +
     func TestHTTP_UsesSharedClient(t *testing.T) {
     	if DefaultClient == nil {
     		t.Fatal("DefaultClient should not be nil")
    
  • internal/safelog/handler.go+150 0 added
    @@ -0,0 +1,150 @@
    +package safelog
    +
    +import (
    +	"context"
    +	"fmt"
    +	"log/slog"
    +	"os"
    +	"strings"
    +	"sync"
    +	"unicode"
    +
    +	"github.com/pinchtab/pinchtab/internal/sanitize"
    +)
    +
    +const (
    +	MaxStringValueBytes = 2 * 1024
    +	MaxRecordTextBytes  = 8 * 1024
    +	redactedValue       = "[REDACTED]"
    +)
    +
    +type Handler struct {
    +	next slog.Handler
    +}
    +
    +var installOnce sync.Once
    +
    +func NewHandler(next slog.Handler) *Handler {
    +	if next == nil {
    +		next = slog.Default().Handler()
    +	}
    +	return &Handler{next: next}
    +}
    +
    +func InstallDefault() {
    +	installOnce.Do(func() {
    +		base := slog.NewTextHandler(os.Stderr, nil)
    +		slog.SetDefault(slog.New(NewHandler(base)))
    +	})
    +}
    +
    +func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
    +	return h.next.Enabled(ctx, level)
    +}
    +
    +func (h *Handler) Handle(ctx context.Context, rec slog.Record) error {
    +	budget := MaxRecordTextBytes
    +	msg, budget := sanitizeText(rec.Message, budget)
    +	out := slog.NewRecord(rec.Time, rec.Level, msg, rec.PC)
    +
    +	rec.Attrs(func(attr slog.Attr) bool {
    +		sanitizedAttr, nextBudget := sanitizeAttr(attr, budget)
    +		budget = nextBudget
    +		out.AddAttrs(sanitizedAttr)
    +		return true
    +	})
    +
    +	return h.next.Handle(ctx, out)
    +}
    +
    +func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
    +	budget := MaxRecordTextBytes
    +	sanitizedAttrs := make([]slog.Attr, 0, len(attrs))
    +	for _, attr := range attrs {
    +		sanitizedAttr, nextBudget := sanitizeAttr(attr, budget)
    +		budget = nextBudget
    +		sanitizedAttrs = append(sanitizedAttrs, sanitizedAttr)
    +	}
    +	return &Handler{next: h.next.WithAttrs(sanitizedAttrs)}
    +}
    +
    +func (h *Handler) WithGroup(name string) slog.Handler {
    +	return &Handler{next: h.next.WithGroup(name)}
    +}
    +
    +func sanitizeAttr(attr slog.Attr, budget int) (slog.Attr, int) {
    +	attr.Value = attr.Value.Resolve()
    +	if isSensitiveKey(attr.Key) {
    +		return slog.String(attr.Key, redactedValue), clampBudget(budget - len(redactedValue))
    +	}
    +
    +	switch attr.Value.Kind() {
    +	case slog.KindString:
    +		s, nextBudget := sanitizeText(attr.Value.String(), budget)
    +		return slog.String(attr.Key, s), nextBudget
    +	case slog.KindGroup:
    +		group := attr.Value.Group()
    +		sanitizedGroup := make([]slog.Attr, 0, len(group))
    +		for _, child := range group {
    +			sanitizedChild, nextBudget := sanitizeAttr(child, budget)
    +			budget = nextBudget
    +			sanitizedGroup = append(sanitizedGroup, sanitizedChild)
    +		}
    +		return slog.Attr{Key: attr.Key, Value: slog.GroupValue(sanitizedGroup...)}, budget
    +	case slog.KindAny:
    +		switch v := attr.Value.Any().(type) {
    +		case error:
    +			s, nextBudget := sanitizeText(v.Error(), budget)
    +			return slog.String(attr.Key, s), nextBudget
    +		case fmt.Stringer:
    +			s, nextBudget := sanitizeText(v.String(), budget)
    +			return slog.String(attr.Key, s), nextBudget
    +		case string:
    +			s, nextBudget := sanitizeText(v, budget)
    +			return slog.String(attr.Key, s), nextBudget
    +		case []byte:
    +			s, nextBudget := sanitizeText(string(v), budget)
    +			return slog.String(attr.Key, s), nextBudget
    +		}
    +	}
    +
    +	return attr, budget
    +}
    +
    +func sanitizeText(s string, budget int) (string, int) {
    +	limit := MaxStringValueBytes
    +	if budget < limit {
    +		limit = budget
    +	}
    +	if limit < 0 {
    +		limit = 0
    +	}
    +	s = sanitize.CleanForLog(s, limit)
    +	return s, clampBudget(budget - len(s))
    +}
    +
    +func clampBudget(v int) int {
    +	if v < 0 {
    +		return 0
    +	}
    +	return v
    +}
    +
    +func isSensitiveKey(key string) bool {
    +	var b strings.Builder
    +	b.Grow(len(key))
    +	for _, r := range key {
    +		if unicode.IsLetter(r) || unicode.IsDigit(r) {
    +			b.WriteRune(unicode.ToLower(r))
    +		}
    +	}
    +	normalized := b.String()
    +	if normalized == "" {
    +		return false
    +	}
    +	return strings.Contains(normalized, "password") ||
    +		strings.Contains(normalized, "cookie") ||
    +		strings.Contains(normalized, "authorization") ||
    +		normalized == "token" ||
    +		strings.HasSuffix(normalized, "token")
    +}
    
  • internal/safelog/handler_test.go+44 0 added
    @@ -0,0 +1,44 @@
    +package safelog
    +
    +import (
    +	"bytes"
    +	"log/slog"
    +	"strings"
    +	"testing"
    +)
    +
    +func TestHandlerRedactsAndSanitizesStringAttrs(t *testing.T) {
    +	var buf bytes.Buffer
    +	logger := slog.New(NewHandler(slog.NewJSONHandler(&buf, nil)))
    +
    +	logger.Info("hello\x1b[31mworld", "token", "secret-token", "path", "/Users/tester/private.txt\x00")
    +
    +	out := buf.String()
    +	if strings.Contains(out, "secret-token") {
    +		t.Fatalf("expected token to be redacted, got %q", out)
    +	}
    +	if strings.Contains(out, "\x1b") {
    +		t.Fatalf("expected ANSI escapes to be stripped, got %q", out)
    +	}
    +	if strings.Contains(out, "\x00") {
    +		t.Fatalf("expected null bytes to be stripped, got %q", out)
    +	}
    +	if !strings.Contains(out, "[REDACTED]") {
    +		t.Fatalf("expected redacted marker, got %q", out)
    +	}
    +}
    +
    +func TestHandlerTruncatesOversizedStrings(t *testing.T) {
    +	var buf bytes.Buffer
    +	logger := slog.New(NewHandler(slog.NewTextHandler(&buf, nil)))
    +
    +	logger.Info("msg", "payload", strings.Repeat("x", MaxStringValueBytes+512))
    +
    +	out := buf.String()
    +	if len(out) == 0 {
    +		t.Fatal("expected log output")
    +	}
    +	if strings.Contains(out, strings.Repeat("x", MaxStringValueBytes+128)) {
    +		t.Fatalf("expected oversized value to be truncated, got %q", out)
    +	}
    +}
    
  • internal/sanitize/text.go+77 0 added
    @@ -0,0 +1,77 @@
    +package sanitize
    +
    +import (
    +	"regexp"
    +	"strings"
    +	"unicode"
    +)
    +
    +const TruncationSuffix = "..."
    +
    +var (
    +	ansiCSI = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
    +	unixAbs = regexp.MustCompile(`(^|[\s"'(=:])((?:/Users|/home|/var|/tmp|/private|/opt|/etc|/Volumes)(?:/[^\s"'():;<>{}\[\]]+)+)`)
    +	winAbs  = regexp.MustCompile(`(^|[\s"'(=:])([A-Za-z]:\\(?:[^\s"'():;<>{}\[\]]+\\?)+)`)
    +)
    +
    +func TruncateUTF8Bytes(s string, maxBytes int) string {
    +	if maxBytes <= 0 {
    +		return ""
    +	}
    +	if len(s) <= maxBytes {
    +		return s
    +	}
    +	if maxBytes <= len(TruncationSuffix) {
    +		return TruncationSuffix[:maxBytes]
    +	}
    +
    +	limit := maxBytes - len(TruncationSuffix)
    +	cut := 0
    +	for i := range s {
    +		if i > limit {
    +			break
    +		}
    +		cut = i
    +	}
    +	if cut == 0 && limit > 0 {
    +		return TruncationSuffix
    +	}
    +	return s[:cut] + TruncationSuffix
    +}
    +
    +func StripANSI(s string) string {
    +	return ansiCSI.ReplaceAllString(s, "")
    +}
    +
    +func StripControlChars(s string) string {
    +	var b strings.Builder
    +	b.Grow(len(s))
    +
    +	lastWasSpace := false
    +	for _, r := range s {
    +		if unicode.IsControl(r) {
    +			if !lastWasSpace {
    +				b.WriteByte(' ')
    +				lastWasSpace = true
    +			}
    +			continue
    +		}
    +		b.WriteRune(r)
    +		lastWasSpace = r == ' '
    +	}
    +	return strings.TrimSpace(b.String())
    +}
    +
    +func RedactAbsolutePaths(s string) string {
    +	s = unixAbs.ReplaceAllString(s, `$1[path]`)
    +	s = winAbs.ReplaceAllString(s, `$1[path]`)
    +	return s
    +}
    +
    +func CleanForLog(s string, maxBytes int) string {
    +	return TruncateUTF8Bytes(StripControlChars(StripANSI(s)), maxBytes)
    +}
    +
    +func CleanError(s string, maxBytes int) string {
    +	return TruncateUTF8Bytes(RedactAbsolutePaths(StripControlChars(StripANSI(s))), maxBytes)
    +}
    
  • internal/sanitize/text_test.go+50 0 added
    @@ -0,0 +1,50 @@
    +package sanitize
    +
    +import "testing"
    +
    +func TestCleanErrorRedactsAbsolutePaths(t *testing.T) {
    +	tests := []struct {
    +		name  string
    +		input string
    +		want  string
    +	}{
    +		{
    +			name:  "short unix path",
    +			input: "/var/log",
    +			want:  "[path]",
    +		},
    +		{
    +			name:  "quoted unix path",
    +			input: `error at "/Users/test/file.txt" failed`,
    +			want:  `error at "[path]" failed`,
    +		},
    +		{
    +			name:  "mixed unix and windows paths",
    +			input: `copy /var/log to C:\Users\test\file.txt`,
    +			want:  `copy [path] to [path]`,
    +		},
    +		{
    +			name:  "colon before unix path",
    +			input: `error:/Users/test/file.txt`,
    +			want:  `error:[path]`,
    +		},
    +		{
    +			name:  "colon before windows path",
    +			input: `error:C:\Users\test\file.txt`,
    +			want:  `error:[path]`,
    +		},
    +		{
    +			name:  "path-like substring inside word is preserved",
    +			input: `description/Users/guide`,
    +			want:  `description/Users/guide`,
    +		},
    +	}
    +
    +	for _, tt := range tests {
    +		t.Run(tt.name, func(t *testing.T) {
    +			if got := CleanError(tt.input, 1024); got != tt.want {
    +				t.Fatalf("CleanError(%q) = %q, want %q", tt.input, got, tt.want)
    +			}
    +		})
    +	}
    +}
    
  • internal/scheduler/batch.go+2 3 modified
    @@ -1,7 +1,6 @@
     package scheduler
     
     import (
    -	"encoding/json"
     	"errors"
     	"log/slog"
     	"net/http"
    @@ -41,8 +40,8 @@ type BatchResponseItem struct {
     
     func (s *Scheduler) handleBatch(w http.ResponseWriter, r *http.Request) {
     	var req BatchRequest
    -	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    -		httpx.Error(w, 400, err)
    +	if err := httpx.DecodeJSONBody(w, r, 0, &req); err != nil {
    +		httpx.Error(w, httpx.StatusForJSONDecodeError(err), err)
     		return
     	}
     
    
  • internal/scheduler/handlers.go+2 3 modified
    @@ -1,7 +1,6 @@
     package scheduler
     
     import (
    -	"encoding/json"
     	"net/http"
     	"strings"
     
    @@ -20,8 +19,8 @@ func (s *Scheduler) RegisterHandlers(mux *http.ServeMux) {
     
     func (s *Scheduler) handleSubmit(w http.ResponseWriter, r *http.Request) {
     	var req SubmitRequest
    -	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    -		httpx.Error(w, 400, err)
    +	if err := httpx.DecodeJSONBody(w, r, 0, &req); err != nil {
    +		httpx.Error(w, httpx.StatusForJSONDecodeError(err), err)
     		return
     	}
     
    
  • internal/scheduler/handlers_test.go+18 0 modified
    @@ -6,6 +6,8 @@ import (
     	"net/http/httptest"
     	"strings"
     	"testing"
    +
    +	"github.com/pinchtab/pinchtab/internal/httpx"
     )
     
     func setupHandlerTest(t *testing.T) (*Scheduler, *http.ServeMux, *httptest.Server) {
    @@ -106,6 +108,22 @@ func TestHandlerSubmitBadJSON(t *testing.T) {
     	}
     }
     
    +func TestHandlerSubmitBodyTooLarge(t *testing.T) {
    +	_, mux, executor := setupHandlerTest(t)
    +	defer executor.Close()
    +
    +	body := `{"agentId":"agent-1","action":"click","ref":"` + strings.Repeat("x", int(httpx.DefaultMaxJSONBodyBytes)) + `"}`
    +	req := httptest.NewRequest("POST", "/tasks", strings.NewReader(body))
    +	req.Header.Set("Content-Type", "application/json")
    +	w := httptest.NewRecorder()
    +
    +	mux.ServeHTTP(w, req)
    +
    +	if w.Code != http.StatusRequestEntityTooLarge {
    +		t.Errorf("expected 413, got %d: %s", w.Code, w.Body.String())
    +	}
    +}
    +
     func TestHandlerGet(t *testing.T) {
     	s, mux, executor := setupHandlerTest(t)
     	defer executor.Close()
    
  • internal/server/bridge.go+1 0 modified
    @@ -71,6 +71,7 @@ func RunBridgeServer(cfg *config.RuntimeConfig) {
     				),
     			),
     		),
    +		MaxHeaderBytes:    maxHeaderBytes,
     		ReadHeaderTimeout: 10 * time.Second,
     		ReadTimeout:       30 * time.Second,
     		WriteTimeout:      60 * time.Second,
    
  • internal/server/limits.go+3 0 added
    @@ -0,0 +1,3 @@
    +package server
    +
    +const maxHeaderBytes = 256 << 10
    
  • internal/server/server.go+1 0 modified
    @@ -197,6 +197,7 @@ func RunDashboard(cfg *config.RuntimeConfig, version string) {
     	srv := &http.Server{
     		Addr:              cfg.Bind + ":" + dashPort,
     		Handler:           handler,
    +		MaxHeaderBytes:    maxHeaderBytes,
     		ReadHeaderTimeout: 10 * time.Second,
     		ReadTimeout:       30 * time.Second,
     		WriteTimeout:      60 * time.Second,
    
  • plugins/README.md+3 0 modified
    @@ -2,6 +2,9 @@
     
     This directory holds plugins for [SMCP](https://github.com/sanctumos/smcp) (Model Context Protocol server for the Animus/Letta/Sanctum ecosystem). SMCP discovers plugins by scanning a directory for `plugins/<name>/cli.py` and running `python cli.py --describe` to get tool schemas.
     
    +> [!WARNING]
    +> These plugins expose PinchTab's privileged browser-control surface to MCP/SMCP clients. They are not designed for untrusted users, shared multi-tenant access, or direct public-internet exposure. If you are unsure how to secure a non-local deployment, review [../docs/guides/security.md](../docs/guides/security.md) and use the private security contact path in [../SECURITY.md](../SECURITY.md) before exposing the service.
    +
     ## SMCP contract (reference)
     
     Instructions below match the SMCP plugin contract. If your SMCP version differs, prefer [sanctumos/smcp](https://github.com/sanctumos/smcp) and its docs.
    
  • README.md+11 3 modified
    @@ -43,6 +43,9 @@ PinchTab is designed first for local, single-user control on a machine you manag
     
     If you run PinchTab on a different machine, do it only when you understand the security model. Keep it on a private or otherwise closed network, avoid exposing it directly to the public internet, and keep high-risk endpoint families disabled unless you explicitly need them. If you do enable them, lock them down so only the systems that need them can reach them.
     
    +> [!WARNING]
    +> The dashboard, HTTP API, MCP server, and remote CLI integrations are privileged operator control surfaces. They are not designed for untrusted users, multi-tenant exposure, or direct public-internet access. If you are unsure how to secure a non-local deployment, review [docs/guides/security.md](docs/guides/security.md) and use the private security contact path in [SECURITY.md](SECURITY.md) before exposing the service.
    +
     
     If you prefer not to run a daemon, or if you're on Windows, you can instead run:
     
    @@ -248,10 +251,15 @@ pinchtab text
     
     Or use the HTTP API directly:
     ```bash
    -# Create an instance (returns instance id)
    -INST=$(curl -s -X POST http://localhost:9867/instances/launch \
    +# Create a profile first (returns profile id)
    +PROF=$(curl -s -X POST http://localhost:9867/profiles \
    +  -H "Content-Type: application/json" \
    +  -d '{"name":"work"}' | jq -r '.id')
    +
    +# Start an instance for that profile (returns instance id)
    +INST=$(curl -s -X POST http://localhost:9867/instances/start \
       -H "Content-Type: application/json" \
    -  -d '{"name":"work","mode":"headless"}' | jq -r '.id')
    +  -d "{\"profileId\":\"$PROF\",\"mode\":\"headless\"}" | jq -r '.id')
     
     # Open a tab in that instance
     TAB=$(curl -s -X POST http://localhost:9867/instances/$INST/tabs/open \
    
  • skills/pinchtab/references/profiles.md+7 3 modified
    @@ -46,12 +46,16 @@ curl http://localhost:9867/profiles/<ID>/instance
     curl http://localhost:9867/profiles/My%20Profile/instance
     ```
     
    -## Launch by name
    +## Start by existing profile
     
     ```bash
    -curl -X POST http://localhost:9867/instances/launch \
    +curl -X POST http://localhost:9867/profiles \
       -H 'Content-Type: application/json' \
    -  -d '{"name": "work", "port": "9868"}'
    +  -d '{"name": "work"}'
    +
    +curl -X POST http://localhost:9867/instances/start \
    +  -H 'Content-Type: application/json' \
    +  -d '{"profileId": "work", "port": "9868"}'
     ```
     
     ## CLI usage with profiles
    
  • skills/pinchtab/SKILL.md+5 2 modified
    @@ -184,9 +184,12 @@ Use this when the agent cannot call the CLI directly.
     
     ```bash
     curl http://localhost:9867/health
    -curl -X POST http://localhost:9867/instances/launch \
    +curl -X POST http://localhost:9867/profiles \
    +  -H "Content-Type: application/json" \
    +  -d '{"name":"work"}'
    +curl -X POST http://localhost:9867/instances/start \
       -H "Content-Type: application/json" \
    -  -d '{"name":"work","headless":true}'
    +  -d '{"profileId":"work","mode":"headless"}'
     curl -X POST http://localhost:9868/action \
       -H "Content-Type: application/json" \
       -d '{"kind":"click","selector":"e5"}'
    
  • tests/e2e/scenarios-api/orchestrator-full.sh+6 6 modified
    @@ -79,7 +79,7 @@ end_test
     # ─────────────────────────────────────────────────────────────────
     start_test "orchestrator: launch new instance"
     
    -pt_post /instances/launch '{"name":"e2e-multi-test","headless":true}'
    +pt_post /instances/start '{"mode":"headless"}'
     assert_ok "launch instance"
     
     INST_ID=$(echo "$RESULT" | jq -r '.id')
    @@ -167,7 +167,7 @@ end_test
     # ─────────────────────────────────────────────────────────────────
     start_test "orchestrator: ID format (inst_ prefix)"
     
    -pt_post /instances/launch '{"name":"e2e-id-format","headless":true}'
    +pt_post /instances/start '{"mode":"headless"}'
     assert_ok "launch"
     ID_CHECK_INST=$(echo "$RESULT" | jq -r '.id')
     
    @@ -182,13 +182,13 @@ start_test "orchestrator: ports, isolation, and cleanup"
     
     ACTIVE_INST_IDS=()
     
    -pt_post /instances/launch '{"name":"e2e-port-1","headless":true}'
    +pt_post /instances/start '{"mode":"headless"}'
     assert_ok "launch 1"
     INST1=$(echo "$RESULT" | jq -r '.id')
     PORT1=$(echo "$RESULT" | jq -r '.port')
     ACTIVE_INST_IDS+=("$INST1")
     
    -pt_post /instances/launch '{"name":"e2e-port-2","headless":true}'
    +pt_post /instances/start '{"mode":"headless"}'
     assert_ok "launch 2"
     INST2=$(echo "$RESULT" | jq -r '.id')
     PORT2=$(echo "$RESULT" | jq -r '.port')
    @@ -207,7 +207,7 @@ assert_ok "stop first instance"
     wait_for_instances_gone "${E2E_SERVER}" 10 "${INST1}" || true
     ACTIVE_INST_IDS=("${INST2}")
     
    -pt_post /instances/launch '{"name":"e2e-reuse-2","headless":true}'
    +pt_post /instances/start '{"mode":"headless"}'
     assert_ok "relaunch"
     INST3=$(echo "$RESULT" | jq -r '.id')
     PORT3=$(echo "$RESULT" | jq -r '.port')
    @@ -245,7 +245,7 @@ else
       ((ASSERTIONS_FAILED++)) || true
     fi
     
    -pt_post /instances/launch '{"name":"e2e-cleanup-3","headless":true}'
    +pt_post /instances/start '{"mode":"headless"}'
     assert_ok "launch cleanup-3"
     INST4=$(echo "$RESULT" | jq -r '.id')
     ACTIVE_INST_IDS+=("$INST4")
    
  • tests/e2e/scenarios-api/system-basic.sh+44 9 modified
    @@ -163,6 +163,38 @@ assert_instance_logs_poll() {
       return 1
     }
     
    +assert_instance_logs_poll_all() {
    +  local inst_id="$1"
    +  local desc="$2"
    +  shift 2
    +
    +  local attempts=15
    +  local delay=1
    +  local i needle ok
    +  for i in $(seq 1 "$attempts"); do
    +    E2E_SERVER=$ORCH_URL pt_get "/instances/${inst_id}/logs" >/dev/null
    +    if [[ "$HTTP_STATUS" =~ ^2 ]]; then
    +      ok=1
    +      for needle in "$@"; do
    +        if ! grep -Fq -- "$needle" <<<"$RESULT"; then
    +          ok=0
    +          break
    +        fi
    +      done
    +      if [ "$ok" -eq 1 ]; then
    +        echo -e "  ${GREEN}✓${NC} $desc"
    +        ((ASSERTIONS_PASSED++)) || true
    +        return 0
    +      fi
    +    fi
    +    sleep "$delay"
    +  done
    +
    +  echo -e "  ${RED}✗${NC} $desc (missing fragments: $*)"
    +  ((ASSERTIONS_FAILED++)) || true
    +  return 1
    +}
    +
     print_extension_hints() {
       local inst_id="${1:-}"
       echo ""
    @@ -196,10 +228,11 @@ assert_ok "navigate"
     
     DEFAULT_LOG_PASS=1
     if [ -n "$DEFAULT_INST_ID" ] && [ "$DEFAULT_INST_ID" != "null" ]; then
    -  assert_instance_logs_poll \
    +  assert_instance_logs_poll_all \
         "$DEFAULT_INST_ID" \
    -    "loading extensions paths=/extensions/test-extension" \
    -    "default instance logs configured extension path"
    +    "default instance logs configured extension path" \
    +    "loading extensions" \
    +    "paths=/extensions/test-extension"
       DEFAULT_LOG_PASS=$?
     
       assert_instance_logs_poll \
    @@ -229,10 +262,11 @@ E2E_SERVER="http://pinchtab:${INST_PORT}"
     wait_for_instance_ready "${E2E_SERVER}"
     E2E_SERVER=$ORIG_URL
     
    -assert_instance_logs_poll \
    +assert_instance_logs_poll_all \
       "$INST_ID" \
    -  "loading extensions paths=/extensions/test-extension" \
    -  "API-started instance logs extension path"
    +  "API-started instance logs extension path" \
    +  "loading extensions" \
    +  "paths=/extensions/test-extension"
     
     end_test
     
    @@ -250,10 +284,11 @@ wait_for_instance_ready "${E2E_SERVER}"
     
     pt_post /navigate "{\"url\":\"${FIXTURES_URL}/index.html\"}"
     assert_ok "navigate"
    -assert_instance_logs_poll \
    +assert_instance_logs_poll_all \
       "$INST_ID" \
    -  "loading extensions paths=/extensions/test-extension,/extensions/test-extension-api" \
    -  "child instance logs merged extension paths"
    +  "child instance logs merged extension paths" \
    +  "loading extensions" \
    +  "paths=/extensions/test-extension,/extensions/test-extension-api"
     MERGE_PASS=$?
     
     assert_instance_logs_poll \
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.