Skip to content
This repository was archived by the owner on Apr 17, 2026. It is now read-only.

Commit 25b3374

Browse files
fix: tighten instance API, runtime safety caps, and security posture docs (pinchtab#368)
* fix: tighten instance API usage and add runtime safety caps * docs: clarify non-default security-reducing configs and presets --------- Co-authored-by: Luigi Agosti <luigi@tengio.com>
1 parent decab3e commit 25b3374

66 files changed

Lines changed: 1612 additions & 220 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ PinchTab is designed first for local, single-user control on a machine you manag
4343

4444
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.
4545

46+
> [!WARNING]
47+
> 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.
48+
4649

4750
If you prefer not to run a daemon, or if you're on Windows, you can instead run:
4851

@@ -248,10 +251,15 @@ pinchtab text
248251

249252
Or use the HTTP API directly:
250253
```bash
251-
# Create an instance (returns instance id)
252-
INST=$(curl -s -X POST http://localhost:9867/instances/launch \
254+
# Create a profile first (returns profile id)
255+
PROF=$(curl -s -X POST http://localhost:9867/profiles \
256+
-H "Content-Type: application/json" \
257+
-d '{"name":"work"}' | jq -r '.id')
258+
259+
# Start an instance for that profile (returns instance id)
260+
INST=$(curl -s -X POST http://localhost:9867/instances/start \
253261
-H "Content-Type: application/json" \
254-
-d '{"name":"work","mode":"headless"}' | jq -r '.id')
262+
-d "{\"profileId\":\"$PROF\",\"mode\":\"headless\"}" | jq -r '.id')
255263

256264
# Open a tab in that instance
257265
TAB=$(curl -s -X POST http://localhost:9867/instances/$INST/tabs/open \

cmd/pinchtab/cmd_security.go

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ func init() {
3232
})
3333
securityCmd.AddCommand(&cobra.Command{
3434
Use: "down",
35-
Short: "Lower guards while keeping loopback bind and API auth enabled",
35+
Short: "Apply a documented security-reducing preset while keeping loopback bind and API auth enabled",
36+
Long: "Applies the guards-down preset for local operator workflows. " +
37+
"This is a documented, non-default, security-reducing configuration change: " +
38+
"sensitive endpoint families and attach are enabled, while IDPI protections are disabled. " +
39+
"Loopback bind and API authentication remain enabled, and attach host allowlisting stays local-only until you widen it explicitly.",
3640
Run: func(cmd *cobra.Command, args []string) {
3741
handleSecurityDownCommand()
3842
},
@@ -181,10 +185,17 @@ func promptSecurityEdit(cfg *config.RuntimeConfig, posture cli.SecurityPosture,
181185
func editSecurityCheck(cfg *config.RuntimeConfig, check cli.SecurityPostureCheck) (*config.RuntimeConfig, bool, error) {
182186
switch check.ID {
183187
case "bind_loopback":
184-
value, err := promptInput("Set server.bind:", cfg.Bind)
188+
value, err := promptInput("Set server.bind (127.0.0.1 keeps it local):", cfg.Bind)
185189
if err != nil {
186190
return nil, false, err
187191
}
192+
if !isLoopbackBindValue(value) {
193+
fmt.Println()
194+
fmt.Println(" " + cli.StyleStdout(cli.WarningStyle, "Warning: server.bind is non-loopback"))
195+
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"))
196+
fmt.Println(" " + cli.StyleStdout(cli.MutedStyle, "scope") + ": " + cli.StyleStdout(cli.ValueStyle, "documented, non-default, security-reducing override"))
197+
fmt.Println(" " + cli.StyleStdout(cli.MutedStyle, "hint") + ": " + cli.StyleStdout(cli.ValueStyle, "keep a token set and review reverse proxy or port-publishing behavior explicitly"))
198+
}
188199
return workflow.UpdateValue("server.bind", value)
189200
case "api_auth_enabled":
190201
picked, err := promptSelect("API authentication", []menuOption{
@@ -230,10 +241,17 @@ func editSecurityCheck(cfg *config.RuntimeConfig, check cli.SecurityPostureCheck
230241
}
231242
return workflow.UpdateValue("security.attach.enabled", fmt.Sprintf("%t", picked == "enable"))
232243
case "attach_local_only":
233-
value, err := promptInput("Set security.attach.allowHosts (comma-separated):", strings.Join(cfg.AttachAllowHosts, ","))
244+
value, err := promptInput("Set security.attach.allowHosts (comma-separated; '*' disables host allowlisting):", strings.Join(cfg.AttachAllowHosts, ","))
234245
if err != nil {
235246
return nil, false, err
236247
}
248+
if attachHostsContainsWildcard(value) {
249+
fmt.Println()
250+
fmt.Println(" " + cli.StyleStdout(cli.WarningStyle, "Warning: security.attach.allowHosts includes '*'"))
251+
fmt.Println(" " + cli.StyleStdout(cli.MutedStyle, "effect") + ": " + cli.StyleStdout(cli.ValueStyle, "disables host allowlisting and allows any reachable attach host with an allowed scheme"))
252+
fmt.Println(" " + cli.StyleStdout(cli.MutedStyle, "scope") + ": " + cli.StyleStdout(cli.ValueStyle, "documented, non-default, security-reducing override"))
253+
fmt.Println(" " + cli.StyleStdout(cli.MutedStyle, "hint") + ": " + cli.StyleStdout(cli.ValueStyle, "use only on isolated, operator-controlled networks"))
254+
}
237255
return workflow.UpdateValue("security.attach.allowHosts", value)
238256
case "idpi_whitelist_scoped":
239257
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
267285
return cfg, false, nil
268286
}
269287

288+
func attachHostsContainsWildcard(value string) bool {
289+
for _, part := range strings.Split(value, ",") {
290+
if strings.TrimSpace(part) == "*" {
291+
return true
292+
}
293+
}
294+
return false
295+
}
296+
297+
func isLoopbackBindValue(value string) bool {
298+
switch strings.TrimSpace(strings.ToLower(value)) {
299+
case "", "127.0.0.1", "localhost", "::1":
300+
return true
301+
default:
302+
return false
303+
}
304+
}
305+
270306
func applySecurityUp() (*config.RuntimeConfig, bool, error) {
271307
configPath, changed, err := workflow.RestoreSecurityDefaults()
272308
if err != nil {
@@ -291,7 +327,10 @@ func applySecurityDown() (*config.RuntimeConfig, bool, error) {
291327
return nextCfg, false, nil
292328
}
293329
fmt.Println(cli.StyleStdout(cli.WarningStyle, fmt.Sprintf("Guards down preset applied in %s", configPath)))
294-
fmt.Println(cli.StyleStdout(cli.MutedStyle, "Loopback bind and API auth remain enabled; sensitive endpoints and attach are enabled, IDPI is disabled."))
330+
fmt.Println(cli.StyleStdout(cli.WarningStyle, "This is a documented, non-default, security-reducing preset."))
331+
fmt.Println(cli.StyleStdout(cli.MutedStyle, "Loopback bind and API auth remain enabled; sensitive endpoints and attach are enabled, and IDPI protections are disabled."))
332+
fmt.Println(cli.StyleStdout(cli.MutedStyle, "Attach host allowlisting remains local-only. Widening allowHosts or enabling bridge schemes later is an additional explicit weakening."))
333+
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."))
295334
return nextCfg, true, nil
296335
}
297336

cmd/pinchtab/cmd_security_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"io"
55
"os"
6+
"path/filepath"
67
"strings"
78
"testing"
89

@@ -41,6 +42,45 @@ func TestHandleSecurityCommandDefaultConfigSkipsEmptySections(t *testing.T) {
4142
}
4243
}
4344

45+
func TestApplySecurityDownPrintsExplicitRiskFraming(t *testing.T) {
46+
configPath := filepath.Join(t.TempDir(), "pinchtab", "config.json")
47+
t.Setenv("PINCHTAB_CONFIG", configPath)
48+
49+
fc := config.DefaultFileConfig()
50+
fc.Server.Token = "guarded-token"
51+
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
52+
t.Fatalf("MkdirAll() error = %v", err)
53+
}
54+
if err := config.SaveFileConfig(&fc, configPath); err != nil {
55+
t.Fatalf("SaveFileConfig() error = %v", err)
56+
}
57+
58+
output := captureStdout(t, func() {
59+
cfg, changed, err := applySecurityDown()
60+
if err != nil {
61+
t.Fatalf("applySecurityDown() error = %v", err)
62+
}
63+
if !changed {
64+
t.Fatal("expected applySecurityDown() to change config")
65+
}
66+
if cfg == nil {
67+
t.Fatal("expected runtime config result")
68+
}
69+
})
70+
71+
for _, needle := range []string{
72+
"Guards down preset applied",
73+
"This is a documented, non-default, security-reducing preset.",
74+
"sensitive endpoints and attach are enabled, and IDPI protections are disabled.",
75+
"Attach host allowlisting remains local-only.",
76+
"Changing server.bind away from 127.0.0.1 later is also an additional explicit weakening",
77+
} {
78+
if !strings.Contains(output, needle) {
79+
t.Fatalf("expected output to contain %q\n%s", needle, output)
80+
}
81+
}
82+
}
83+
4484
func captureStdout(t *testing.T, fn func()) string {
4585
t.Helper()
4686

cmd/pinchtab/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/pinchtab/pinchtab/internal/cli"
88
"github.com/pinchtab/pinchtab/internal/config"
9+
"github.com/pinchtab/pinchtab/internal/safelog"
910
"github.com/pinchtab/pinchtab/internal/server"
1011
"github.com/spf13/cobra"
1112
)
@@ -95,6 +96,7 @@ func menuListenStatus(cfg *config.RuntimeConfig) string {
9596
}
9697

9798
func Execute() {
99+
safelog.InstallDefault()
98100
if err := rootCmd.Execute(); err != nil {
99101
fmt.Fprintln(os.Stderr, err)
100102
os.Exit(1)

dashboard/src/pages/SettingsPage.tsx

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,15 @@ export default function SettingsPage() {
267267
: [];
268268
const idpiWildcard = idpiAllowedDomains.includes("*");
269269
const idpiDomainsConfigured = idpiAllowedDomains.length > 0 && !idpiWildcard;
270+
const attachAllowedHosts = backendConfig
271+
? backendConfig.security.attach.allowHosts
272+
: [];
273+
const attachWildcard = attachAllowedHosts.includes("*");
274+
const nonLoopbackBind = backendConfig
275+
? !["127.0.0.1", "localhost", "::1", ""].includes(
276+
backendConfig.server.bind.trim().toLowerCase(),
277+
)
278+
: false;
270279

271280
const updateBackendSection = <K extends keyof BackendConfig>(
272281
section: K,
@@ -1273,15 +1282,35 @@ export default function SettingsPage() {
12731282
</SettingRow>
12741283
<SettingRow
12751284
label="Bind address"
1276-
description="Network interface the dashboard process binds to."
1285+
description="Network interface the dashboard process binds to. Keeping 127.0.0.1 or localhost limits direct reachability to the local machine."
12771286
>
1278-
<input
1279-
value={backendConfig.server.bind}
1280-
onChange={(e) =>
1281-
updateBackendSection("server", { bind: e.target.value })
1282-
}
1283-
className={fieldClass}
1284-
/>
1287+
<div className="space-y-2">
1288+
<input
1289+
value={backendConfig.server.bind}
1290+
onChange={(e) =>
1291+
updateBackendSection("server", {
1292+
bind: e.target.value,
1293+
})
1294+
}
1295+
className={fieldClass}
1296+
/>
1297+
{nonLoopbackBind ? (
1298+
<div className="rounded-sm border border-destructive/35 bg-destructive/10 px-3 py-2 text-xs leading-5 text-destructive/80">
1299+
A non-loopback bind is a documented, non-default,
1300+
security-reducing configuration change. It may expose
1301+
the server beyond the local machine unless another
1302+
network boundary still restricts access. Keep a token
1303+
set and review proxy or port-publishing behavior
1304+
explicitly.
1305+
</div>
1306+
) : (
1307+
<div className="rounded-sm border border-warning/25 bg-warning/10 px-3 py-2 text-xs leading-5 text-warning">
1308+
Loopback bind keeps direct server reachability local.
1309+
Moving to <code>0.0.0.0</code> or another non-local
1310+
address widens the trust boundary.
1311+
</div>
1312+
)}
1313+
</div>
12851314
</SettingRow>
12861315
<SettingRow
12871316
label="API token"
@@ -1363,7 +1392,9 @@ export default function SettingsPage() {
13631392
</SettingRow>
13641393
<SettingRow
13651394
label="Allowed attach hosts"
1366-
description="Comma-separated host allowlist for attach requests. Only include hosts you control and trust."
1395+
description={
1396+
'Comma-separated host allowlist for attach requests. Only include hosts you control and trust. Using "*" disables host allowlisting.'
1397+
}
13671398
>
13681399
<div className="space-y-2">
13691400
<input
@@ -1380,11 +1411,22 @@ export default function SettingsPage() {
13801411
}
13811412
className={fieldClass}
13821413
/>
1383-
<div className="rounded-sm border border-warning/25 bg-warning/10 px-3 py-2 text-xs leading-5 text-warning">
1384-
Hosts in this allowlist may be used for remote attach
1385-
requests. Broad or untrusted entries can expose external
1386-
Chrome sessions and browser contents.
1387-
</div>
1414+
{attachWildcard ? (
1415+
<div className="rounded-sm border border-destructive/35 bg-destructive/10 px-3 py-2 text-xs leading-5 text-destructive/80">
1416+
<code>allowHosts: ["*"]</code> is a documented,
1417+
non-default, security-reducing override. It disables
1418+
host allowlisting entirely and allows remote attach
1419+
requests to any reachable host with an allowed scheme.
1420+
Use it only on isolated, operator-controlled networks.
1421+
</div>
1422+
) : (
1423+
<div className="rounded-sm border border-warning/25 bg-warning/10 px-3 py-2 text-xs leading-5 text-warning">
1424+
Hosts in this allowlist may be used for remote attach
1425+
requests. Broad or untrusted entries expand the trust
1426+
boundary and can expose external Chrome sessions and
1427+
browser contents.
1428+
</div>
1429+
)}
13881430
</div>
13891431
</SettingRow>
13901432
<SettingRow

docs/commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ pinchtab config set <path> <val> # Set one file-config value
180180
pinchtab config patch <json> # Merge JSON into the config file
181181
pinchtab security # Interactive security overview
182182
pinchtab security up # Apply stricter defaults
183-
pinchtab security down # Relax defaults
183+
pinchtab security down # Apply documented guards-down preset
184184
```
185185
186186
## Global Flags

docs/dashboard.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ You can open the dashboard at:
1212
- `http://localhost:9867`
1313
- `http://localhost:9867/dashboard`
1414

15+
> [!WARNING]
16+
> 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.
17+
1518
---
1619

1720
## Dashboard overview

docs/endpoints.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,8 @@ POST /instances/{id}/tab
308308
Notes:
309309

310310
- `/instances/start` and `/instances/launch` use `mode`, not `headless`
311+
- `/instances/launch` is a compatibility alias over `/instances/start`
312+
- create profiles explicitly with `POST /profiles`; `name` is no longer supported on `/instances/launch`
311313
- `/profiles/{id}/start` uses `headless`
312314
- attach routes are gated by `security.attach`
313315

@@ -348,6 +350,8 @@ Scheduler routes are only present when `scheduler.enabled` is true.
348350

349351
Some endpoints are intentionally disabled unless the matching config allows them:
350352

353+
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.
354+
351355
- `/evaluate` and `/tabs/{id}/evaluate` -> `security.allowEvaluate`
352356
- `/download` and `/tabs/{id}/download` -> `security.allowDownload`
353357
- `/upload` and `/tabs/{id}/upload` -> `security.allowUpload`

docs/guides/attach-chrome.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ Recommended rules:
200200
- set `PINCHTAB_TOKEN` when the server is reachable outside localhost
201201
- only attach to CDP endpoints you trust
202202

203+
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.
204+
203205
Also remember:
204206

205207
- Chrome DevTools gives powerful browser control

docs/guides/mcp-agents.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
This guide walks through setting up PinchTab as an MCP tool server for AI coding assistants and agent frameworks.
44

5+
> [!WARNING]
6+
> 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.
7+
58
## What is MCP?
69

710
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:
152155
153156
Restart PinchTab after changing this setting.
154157
155-
> **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.
158+
> **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.
156159
157160
## Connecting to a Remote PinchTab
158161

0 commit comments

Comments
 (0)