-
Notifications
You must be signed in to change notification settings - Fork 32
Expand file tree
/
Copy pathproxy.ts
More file actions
134 lines (124 loc) · 6.02 KB
/
Copy pathproxy.ts
File metadata and controls
134 lines (124 loc) · 6.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
import { NextRequest, NextResponse } from "next/server";
import {
hostnameFromHostHeader,
isLoopbackHostname,
resolveDashboardHost,
} from "./lib/dashboard-host";
/** Methods that can change something. Safe methods are not origin-checked. */
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/**
* Deliberately terse, and identical whichever check failed: this is an
* unauthenticated local surface, and a body naming the failed check is a probing
* aid. The detail goes to the server log, where only the operator sees it.
*/
function forbid(): NextResponse {
return new NextResponse("Forbidden", { status: 403, headers: { "cache-control": "no-store" } });
}
/**
* Refuse anything that is not a same-machine, same-origin request.
*
* The dashboard has no authentication and is a write surface for this machine's
* security configuration — `removeHooksWebAction` strips failproofai's hooks out
* of every agent CLI's settings file, and `togglePolicyAction` disables
* individual policies. Two checks stand between an ordinary malicious web page
* and that, and they defeat different attacks:
*
* **Host** — a DNS-rebinding page (a domain whose second lookup returns
* 127.0.0.1) arrives at the loopback socket with `Host: attacker.tld`. Because
* its Origin matches that Host, every same-origin check in the framework passes;
* Next's own action-handler comparison is `originHost !== host.value`, which is
* satisfied. Pinning Host to loopback is what makes rebinding fail, and a
* loopback *bind* does not do it — rebinding targets 127.0.0.1 by design.
*
* **Origin** — an ordinary drive-by does not need rebinding for the route
* handlers, which get none of the Server-Action protection. `req.json()` ignores
* Content-Type, so `fetch(..., {method:"POST", body:'{...}'})` from any site is a
* CORS *simple* request: no preflight, the request is delivered, the side effect
* lands, and the attacker never needs to read the response.
*
* A request with no Origin at all is allowed ONLY on a loopback bind: it is a
* non-browser caller, and on loopback that is necessarily a local process —
* which can already read and rewrite these files directly, so refusing it buys
* nothing. That reasoning is entirely about the bind address, and the exemption
* used to ignore it. `dashboard-host.ts` documents and supports a deliberate
* non-loopback bind (`--host`, `FAILPROOFAI_DASHBOARD_HOST`) for containers and
* remote dev boxes, and on one of those "no Origin" describes every `curl` on
* the network segment — including one grafting an OTP token into `auth.json`
* via `/api/auth/login-verify`, uninstalling every CLI's hooks via `POST
* /policies`, or starting a scan via `POST /api/audit/run`. Layer 1 is absent
* by the operator's choice and layer 2 cannot apply, so this is the only layer
* left; exempting the commonest non-browser shape from it left nothing at all.
*/
export async function proxy(request: NextRequest): Promise<NextResponse> {
const bindHost = resolveDashboardHost(undefined, process.env.FAILPROOFAI_DASHBOARD_HOST);
const boundToLoopback = isLoopbackHostname(bindHost);
const hostHeader = request.headers.get("host");
// Only meaningful when we are on loopback. If the operator deliberately bound
// a routable address they have accepted reachability, and we cannot know which
// Host they intend to answer to.
if (boundToLoopback) {
if (!hostHeader) {
console.warn("[failproofai] refused a request with no Host header");
return forbid();
}
if (!isLoopbackHostname(hostnameFromHostHeader(hostHeader))) {
console.warn(`[failproofai] refused a request with Host: ${hostHeader} (expected loopback)`);
return forbid();
}
}
const origin = request.headers.get("origin");
if (MUTATING_METHODS.has(request.method)) {
if (!origin) {
// See the header: only a loopback bind makes "no Origin" mean "a local
// process that could do this by editing the files anyway".
if (!boundToLoopback) {
console.warn(
`[failproofai] refused a ${request.method} with no Origin header — ` +
`the dashboard is bound to ${bindHost}, not loopback, so an Origin-less ` +
`request is not necessarily local`,
);
return forbid();
}
} else {
let originHost: string | null = null;
try {
// "null" (sandboxed iframes, some redirects) is a valid Origin value and
// must not parse into anything permissive — the URL constructor throws on
// it, which is the behaviour we want.
originHost = new URL(origin).host.toLowerCase();
} catch {
originHost = null;
}
// Compare the full authority, not just the hostname: another app on
// localhost:3000 is a different origin and has no business POSTing here.
if (!originHost || originHost !== (hostHeader ?? "").toLowerCase()) {
console.warn(`[failproofai] refused a cross-origin ${request.method} from ${origin}`);
return forbid();
}
}
}
const { pathname } = request.nextUrl;
if (pathname === "/") {
const disabled = (process.env.FAILPROOFAI_DISABLE_PAGES ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (!disabled.includes("policies")) {
return NextResponse.redirect(new URL("/policies", request.url));
}
if (!disabled.includes("projects")) {
return NextResponse.redirect(new URL("/projects", request.url));
}
}
// Next's own Host resolution prefers `x-forwarded-host` over `Host`, so a
// caller that can set headers could otherwise satisfy the action handler's
// origin comparison against a value it supplied itself. Nothing proxies this
// server, so the header is never legitimate here — drop it before it reaches
// any framework code that trusts it.
const headers = new Headers(request.headers);
headers.delete("x-forwarded-host");
return NextResponse.next({ request: { headers } });
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon\\.ico|icon\\.png).*)"],
};