Skip to content

Commit ab3d5c4

Browse files
authored
feat(server): self-explaining 403 for rejected forwarded hosts (#568)
## Summary Follow-up to the 403 path discussed in #562. When agentsview is reached through SSH port-forwarding, a reverse proxy, or a remote dev environment (exe.dev, Codespaces, Coder, WSL2), the browser sends a `Host` the server does not trust, so `/api/v1/settings` is rejected with a bare `403 Forbidden` — with no server log and no body explaining why. The only escape was already knowing to pass `--public-url`. This makes the existing behavior self-explaining without loosening it: - **Server:** on a host-check rejection, `hostCheckMiddleware` writes a breadcrumb to the debug log (rejected `Host`, the allowed set, and a `--public-url` hint) and returns a descriptive `403` body instead of bare "Forbidden". Fails closed exactly as before — the DNS-rebinding guard is unchanged. - **Frontend:** the settings store surfaces an actionable origin-rejection message on `403` (preferring the server's descriptive body), rather than the generic error. Complements #563, which stopped `403` from showing the misleading auth-token prompt. - **Docs:** a "Remote / forwarded access" section in the README covering the forwarded-host case and the `--public-url` / `--public-origin` flags. Verified end-to-end against the compiled binary on a single machine, including a real `socat` port-forward (equivalent to `ssh -L`) that reproduces the bare 403, and confirmation that `--public-url <forwarded-origin>` resolves it while an untrusted Host still gets 403. The breadcrumb lands in `<dataDir>/debug.log` — the same file referenced in #562. Refs #562 Co-authored-by: Matthew Jacobs <mjacobs@users.noreply.github.com>
1 parent ad32428 commit ab3d5c4

5 files changed

Lines changed: 116 additions & 3 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,29 @@ On first run, agentsview discovers sessions from every supported agent on your
4444
machine, syncs them into a local SQLite database, and opens a web UI at
4545
`http://127.0.0.1:8080`.
4646

47+
## Remote / forwarded access
48+
49+
agentsview binds to loopback and validates the request `Host` header to guard
50+
against DNS-rebinding attacks. When you reach it through SSH port-forwarding, a
51+
reverse proxy, or a remote dev environment (exe.dev, Codespaces, Coder, WSL2),
52+
the browser sends a `Host` that the server does not recognize, so API requests
53+
such as `/api/v1/settings` are rejected with `403 Forbidden`.
54+
55+
To fix this, restart the server with `--public-url` set to the exact origin you
56+
open in the browser:
57+
58+
```bash
59+
# Browser opens http://127.0.0.1:18080 via `ssh -L 18080:127.0.0.1:8080 host`
60+
agentsview serve --public-url http://127.0.0.1:18080
61+
62+
# Browser opens a forwarded hostname
63+
agentsview serve --public-url https://your-workspace.exe.dev
64+
```
65+
66+
Use `--public-origin` (repeatable or comma-separated) to trust additional
67+
browser origins. If you expose the UI beyond loopback, also enable
68+
`--require-auth`.
69+
4770
## Docker
4871

4972
The container image defaults to local `agentsview serve`. Set `PG_SERVE=1` to

frontend/src/lib/stores/settings.svelte.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ import {
77
isRemoteConnection,
88
} from "../api/client.js";
99

10+
/** Build an actionable message for a 403 from the settings API. A
11+
* 403 means the server rejected the request origin/Host (not that a
12+
* token is required), which typically happens behind SSH
13+
* port-forwarding, a reverse proxy, or a remote dev environment.
14+
* Newer servers return a descriptive body; for older servers that
15+
* return a bare "Forbidden", supply the actionable hint ourselves. */
16+
function forbiddenMessage(serverMessage: string): string {
17+
const detail = serverMessage.trim();
18+
if (detail && detail.toLowerCase() !== "forbidden") {
19+
return detail;
20+
}
21+
return (
22+
"Server rejected this origin. If you are reaching agentsview " +
23+
"through SSH port-forwarding, a reverse proxy, or a remote dev " +
24+
"environment, restart it with --public-url <origin> matching the " +
25+
"URL in your browser."
26+
);
27+
}
28+
1029
class SettingsStore {
1130
agentDirs: Record<string, string[]> = $state({});
1231
githubConfigured: boolean = $state(false);
@@ -46,6 +65,8 @@ class SettingsStore {
4665
} catch (e) {
4766
if (e instanceof ApiError && e.status === 401) {
4867
this.needsAuth = true;
68+
} else if (e instanceof ApiError && e.status === 403) {
69+
this.error = forbiddenMessage(e.message);
4970
} else {
5071
this.error =
5172
e instanceof Error ? e.message : "Failed to load settings";

frontend/src/lib/stores/settings.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,29 @@ describe("SettingsStore.load auth handling", () => {
4848
expect(settings.error).toBeNull();
4949
});
5050

51-
it("does not prompt for a token on non-auth 403 responses", async () => {
51+
it("surfaces an actionable hint on a bare 403", async () => {
5252
vi.mocked(api.getSettings).mockRejectedValue(
5353
new ApiError(403, "Forbidden"),
5454
);
5555

5656
await settings.load();
5757

5858
expect(settings.needsAuth).toBe(false);
59-
expect(settings.error).toBe("Forbidden");
59+
expect(settings.error).toContain("--public-url");
60+
});
61+
62+
it("preserves a descriptive 403 body from the server", async () => {
63+
const detail =
64+
'Forbidden: request Host "127.0.0.1:18080" is not in the ' +
65+
"allowed set [127.0.0.1:8080 localhost:8080]. restart with " +
66+
"--public-url http://127.0.0.1:18080.";
67+
vi.mocked(api.getSettings).mockRejectedValue(
68+
new ApiError(403, detail),
69+
);
70+
71+
await settings.load();
72+
73+
expect(settings.needsAuth).toBe(false);
74+
expect(settings.error).toBe(detail);
6075
});
6176
});

internal/server/server.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net"
1010
"net/http"
1111
"net/url"
12+
"sort"
1213
"strconv"
1314
"strings"
1415
gosync "sync"
@@ -617,8 +618,17 @@ func hostCheckMiddleware(
617618
hostAllowed = isAllowedBindAllHost(r.Host, port, allowedIPs)
618619
}
619620
if !hostAllowed {
621+
allowed := sortedHosts(allowedHosts)
622+
log.Printf(
623+
"host check rejected %s %s: Host %q not in allowed "+
624+
"set %v; if reaching agentsview through a forwarded "+
625+
"port or remote host, restart with --public-url "+
626+
"<origin> matching your browser URL",
627+
r.Method, r.URL.Path, r.Host, allowed,
628+
)
620629
http.Error(
621-
w, "Forbidden", http.StatusForbidden,
630+
w, hostRejectionMessage(r.Host, allowed),
631+
http.StatusForbidden,
622632
)
623633
return
624634
}
@@ -627,6 +637,33 @@ func hostCheckMiddleware(
627637
})
628638
}
629639

640+
// sortedHosts returns the allowed Host header values as a sorted
641+
// slice for deterministic log and error output.
642+
func sortedHosts(hosts map[string]bool) []string {
643+
out := make([]string, 0, len(hosts))
644+
for h := range hosts {
645+
out = append(out, h)
646+
}
647+
sort.Strings(out)
648+
return out
649+
}
650+
651+
// hostRejectionMessage builds a self-explaining 403 body for a
652+
// rejected Host header. It names the offending Host, lists the
653+
// allowed values, and points at --public-url so users behind SSH
654+
// port-forwarding, reverse proxies, or remote dev environments
655+
// (exe.dev, Codespaces, Coder, WSL2) can diagnose without devtools.
656+
func hostRejectionMessage(host string, allowed []string) string {
657+
return fmt.Sprintf(
658+
"Forbidden: request Host %q is not in the allowed set %v. "+
659+
"If you are reaching agentsview through SSH port-forwarding, "+
660+
"a reverse proxy, or a remote dev environment, restart the "+
661+
"server with --public-url <origin> matching the URL in your "+
662+
"browser (for example --public-url http://%s).",
663+
host, allowed, host,
664+
)
665+
}
666+
630667
// httpOrigin formats an HTTP origin string. It uses
631668
// net.JoinHostPort to handle IPv6 bracket formatting correctly
632669
// (e.g., [::1]:8080). Browsers omit the port from the Origin

internal/server/server_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1577,6 +1577,23 @@ func TestHostHeaderRejectsDNSRebinding(t *testing.T) {
15771577
assertStatus(t, w, http.StatusForbidden)
15781578
}
15791579

1580+
func TestHostHeaderRejectionBodyIsDescriptive(t *testing.T) {
1581+
te := setup(t)
1582+
1583+
// A forwarded port produces a Host the server does not trust.
1584+
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil)
1585+
req.Host = "127.0.0.1:18080"
1586+
w := httptest.NewRecorder()
1587+
te.srv.Handler().ServeHTTP(w, req)
1588+
1589+
assertStatus(t, w, http.StatusForbidden)
1590+
body := w.Body.String()
1591+
// The body must name the rejected Host and point at the fix so a
1592+
// user can self-diagnose without devtools.
1593+
assert.Contains(t, body, "127.0.0.1:18080")
1594+
assert.Contains(t, body, "--public-url")
1595+
}
1596+
15801597
func TestHostHeaderAllowsLegitimate(t *testing.T) {
15811598
te := setup(t)
15821599

0 commit comments

Comments
 (0)