Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions bridge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,17 +150,31 @@ func safeUser() string {
func isElevated() bool {
if runtime.GOOS == "windows" {
u, err := osuser.Current()
if err == nil && strings.EqualFold(u.Username, "Administrator") {
return true
currentUser := ""
if err == nil {
currentUser = u.Username
}
return strings.EqualFold(os.Getenv("USERNAME"), "Administrator")
return isWindowsAdministrator(currentUser, os.Getenv("USERNAME"))
}
if os.Getenv("SUDO_UID") != "" {
return true
}
return syscall.Geteuid() == 0
}

func isWindowsAdministrator(currentUser, envUser string) bool {
return windowsUsernameLeaf(currentUser) == "administrator" ||
windowsUsernameLeaf(envUser) == "administrator"
}

func windowsUsernameLeaf(value string) string {
value = strings.TrimSpace(value)
if idx := strings.LastIndexAny(value, `\/`); idx >= 0 {
value = value[idx+1:]
}
return strings.ToLower(value)
}

func hostnameSafe() string {
h, err := os.Hostname()
if err != nil {
Expand Down
21 changes: 21 additions & 0 deletions bridge/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,27 @@ func TestShellMetadataHelpers(t *testing.T) {
}
}

func TestWindowsAdminUsernameDetection(t *testing.T) {
cases := []struct {
name string
currentUser string
envUser string
want bool
}{
{name: "plain administrator", currentUser: "Administrator", want: true},
{name: "domain administrator", currentUser: `WINBOX\Administrator`, want: true},
{name: "env administrator fallback", envUser: "Administrator", want: true},
{name: "normal user", currentUser: `WINBOX\bagas`, envUser: "bagas", want: false},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
if got := isWindowsAdministrator(tt.currentUser, tt.envUser); got != tt.want {
t.Fatalf("isWindowsAdministrator() = %v, want %v", got, tt.want)
}
})
}
}

func TestOneShotArgs(t *testing.T) {
name, args := oneShotArgs("echo ok")
if runtime.GOOS == "windows" {
Expand Down
65 changes: 56 additions & 9 deletions server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ describe("session API", () => {
expect(ws.sent.join("\n")).not.toContain('"type":"command"');
});

test("rejects command POST bodies over 64KB before sending them to the bridge", async () => {
test("accepts command POST bodies up to 10MB and rejects larger bodies", async () => {
const code = create("151515151515");
const ws = wsStub();
handleJoin(ws as never, {
Expand All @@ -207,23 +207,42 @@ describe("session API", () => {
meta: { host: "test", os: "linux", arch: "x64", user: "test" },
});

const res = await commandRoute(routeReq(
const justUnderLimit = "x".repeat((10 * 1024 * 1024) - 64);
const pending = commandRoute(routeReq(
`http://test.local/api/session/${code}/run`,
{ code },
{
method: "POST",
body: JSON.stringify({ cmd: "x".repeat(64 * 1024) }),
body: JSON.stringify({ cmd: justUnderLimit }),
},
));
await Bun.sleep(0);
const sent = ws.sent
.map((message) => JSON.parse(message) as { id?: string; type: string; cmd?: string })
.find((message) => message.type === "command");
expect(sent?.cmd).toBe(justUnderLimit);
handleAgentMessage(ws as never, JSON.stringify({
type: "command_result",
id: sent!.id,
output: "ok",
exit_code: 0,
}));
expect((await pending).status).toBe(200);

expect(res.status).toBe(413);
expect(await json(res)).toEqual({
error: "Request body must be 65536 bytes or smaller",
});
expect(ws.sent.join("\n")).not.toContain('"type":"command"');
const oversized = await commandRoute(routeReq(
`http://test.local/api/session/${code}/run`,
{ code },
{
method: "POST",
body: JSON.stringify({ cmd: "x".repeat(10 * 1024 * 1024) }),
},
));

expect(oversized.status).toBe(413);
expect(await json(oversized)).toEqual({ error: "Request body too large" });
});

test("renders prompt as non-interactive one-shot command guidance", () => {
test("renders prompt without request payload size guidance", () => {
const session = createSession(track("222222222222"));
const prompt = buildPrompt(
toSessionResponse(session, "http://test.local"),
Expand All @@ -233,6 +252,34 @@ describe("session API", () => {
expect(prompt).toContain("non-interactive shell command access");
expect(prompt).toContain("cmd_b64");
expect(prompt).toContain("http://test.local/api/session/222222222222/run?cmd=");
expect(prompt).not.toContain("POST bodies over");
expect(prompt).not.toContain("10MB");
expect(prompt).not.toContain("64KB");
});

test("shows elevated Windows administrator metadata in prompt", () => {
const session = createSession(track("232323232323"));
const ws = wsStub();
handleJoin(ws as never, {
type: "join",
session: session.code,
role: "agent",
meta: {
host: "winbox",
os: "win32",
arch: "x64",
user: "WINBOX\\Administrator",
cwd: "C:\\Users\\Administrator",
shell: "powershell.exe",
elevated: true,
},
});

const body = toSessionResponse(session, "http://test.local");
expect(body.meta.elevated).toBe(true);
const prompt = buildPrompt(body, "http://test.local");
expect(prompt).toContain("**Elevated:** yes");
expect(prompt).toContain("WINBOX\\Administrator@winbox");
});
});

Expand Down
8 changes: 4 additions & 4 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ function env(key: string, fallback?: string): string {
export const PORT = parseInt(env("PORT", "8765"), 10);
export const HOST = env("HOST", "0.0.0.0");
const SESSION_IDLE_TIMEOUT = parseInt(env("SESSION_IDLE_TIMEOUT", "300"), 10);
const MAX_COMMAND_BODY_BYTES = 64 * 1024;
const MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024;
const MIN_COMMAND_TIMEOUT_SECONDS = 1;
const MAX_COMMAND_TIMEOUT_SECONDS = 60 * 60;

Expand Down Expand Up @@ -414,18 +414,18 @@ async function getCommand(

class PayloadTooLargeError extends Error {
constructor() {
super(`Request body must be ${MAX_COMMAND_BODY_BYTES} bytes or smaller`);
super("Request body too large");
}
}

async function readJsonBody(req: Request): Promise<unknown> {
const contentLength = Number(req.headers.get("Content-Length") || "0");
if (contentLength > MAX_COMMAND_BODY_BYTES) {
if (contentLength > MAX_REQUEST_BODY_BYTES) {
throw new PayloadTooLargeError();
}

const raw = await req.text();
if (new TextEncoder().encode(raw).byteLength > MAX_COMMAND_BODY_BYTES) {
if (new TextEncoder().encode(raw).byteLength > MAX_REQUEST_BODY_BYTES) {
throw new PayloadTooLargeError();
}
return JSON.parse(raw);
Expand Down
2 changes: 1 addition & 1 deletion server/static/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ For multi-line scripts, complex quoting, pipes, JSON, or special characters, sen

Run one command at a time; wait for a response before sending the next.

Default timeout is 30s. Override with `{"timeout": N}` where N is 1-3600 seconds. POST bodies over 64KB are rejected. Output is truncated at 131072 bytes; pipe through `head`, `tail`, or filters proactively. stdout and stderr are merged in `output`; response JSON includes `exit_code` for checking command success and `truncated` for detecting capped output.
Default timeout is 30s. Override with `{"timeout": N}` where N is 1-3600 seconds. Output is truncated at 131072 bytes; pipe through `head`, `tail`, or filters proactively. stdout and stderr are merged in `output`; response JSON includes `exit_code` for checking command success and `truncated` for detecting capped output.

### curl (always preferred)

Expand Down
Loading