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
12 changes: 9 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ ARG CHAT_UI_URL=http://127.0.0.1:18789
ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1
ARG NEMOCLAW_INFERENCE_API=openai-completions
ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=
# Set to "1" to disable device-pairing auth (development/headless only).
# Default: "0" (device auth enabled β€” secure by default).
ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0
# Unique per build to ensure each image gets a fresh auth token.
# Pass --build-arg NEMOCLAW_BUILD_ID=$(date +%s) to bust the cache.
ARG NEMOCLAW_BUILD_ID=default
Expand All @@ -67,7 +70,8 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \
CHAT_UI_URL=${CHAT_UI_URL} \
NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \
NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \
NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64}
NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} \
NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH}

WORKDIR /sandbox
USER sandbox
Expand All @@ -91,6 +95,8 @@ parsed = urlparse(chat_ui_url); \
chat_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else 'http://127.0.0.1:18789'; \
origins = ['http://127.0.0.1:18789']; \
origins = list(dict.fromkeys(origins + [chat_origin])); \
disable_device_auth = os.environ.get('NEMOCLAW_DISABLE_DEVICE_AUTH', '') == '1'; \
allow_insecure = parsed.scheme == 'http'; \
providers = { \
provider_key: { \
'baseUrl': inference_base_url, \
Expand All @@ -106,8 +112,8 @@ config = { \
'gateway': { \
'mode': 'local', \
'controlUi': { \
'allowInsecureAuth': True, \
'dangerouslyDisableDeviceAuth': True, \
'allowInsecureAuth': allow_insecure, \
'dangerouslyDisableDeviceAuth': disable_device_auth, \
'allowedOrigins': origins, \
}, \
'trustedProxies': ['127.0.0.1', '::1'], \
Expand Down
6 changes: 6 additions & 0 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,12 @@ function patchStagedDockerfile(
/^ARG NEMOCLAW_BUILD_ID=.*$/m,
`ARG NEMOCLAW_BUILD_ID=${buildId}`,
);
// Onboard flow expects immediate dashboard access without device pairing,
// so disable device auth for images built during onboard (see #1217).
dockerfile = dockerfile.replace(
/^ARG NEMOCLAW_DISABLE_DEVICE_AUTH=.*$/m,
`ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1`,
);
fs.writeFileSync(dockerfilePath, dockerfile);
}

Expand Down
29 changes: 24 additions & 5 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
# The config hash is verified at startup to detect tampering.
#
# Optional env:
# NVIDIA_API_KEY API key for NVIDIA-hosted inference
# CHAT_UI_URL Browser origin that will access the forwarded dashboard
# NVIDIA_API_KEY API key for NVIDIA-hosted inference
# CHAT_UI_URL Browser origin that will access the forwarded dashboard
# NEMOCLAW_DISABLE_DEVICE_AUTH Build-time only. Set to "1" to skip device-pairing auth
# (development/headless). Has no runtime effect β€” openclaw.json
# is baked at image build and verified by hash at startup.

set -euo pipefail

Expand Down Expand Up @@ -153,6 +156,13 @@ OPENCLAW = os.environ.get('OPENCLAW_BIN', 'openclaw')
DEADLINE = time.time() + 600
QUIET_POLLS = 0
APPROVED = 0
HANDLED = set() # Track rejected/approved requestIds to avoid reprocessing
# SECURITY NOTE: clientId/clientMode are client-supplied and spoofable
# (the gateway stores connectParams.client.id verbatim). This allowlist
# is defense-in-depth, not a trust boundary. PR #690 adds one-shot exit,
# timeout reduction, and token cleanup for a more comprehensive fix.
ALLOWED_CLIENTS = {'openclaw-control-ui'}
ALLOWED_MODES = {'webchat'}

def run(*args):
proc = subprocess.run(args, capture_output=True, text=True)
Expand All @@ -176,13 +186,22 @@ while time.time() < DEADLINE:
if pending:
QUIET_POLLS = 0
for device in pending:
request_id = (device or {}).get('requestId')
if not request_id:
if not isinstance(device, dict):
continue
request_id = device.get('requestId')
if not request_id or request_id in HANDLED:
continue
client_id = device.get('clientId', '')
client_mode = device.get('clientMode', '')
if client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES:
HANDLED.add(request_id)
print(f'[auto-pair] rejected unknown client={client_id} mode={client_mode}')
continue
arc, aout, aerr = run(OPENCLAW, 'devices', 'approve', request_id, '--json')
HANDLED.add(request_id)
if arc == 0:
APPROVED += 1
print(f'[auto-pair] approved request={request_id}')
print(f'[auto-pair] approved request={request_id} client={client_id}')
elif aout or aerr:
print(f'[auto-pair] approve failed request={request_id}: {(aerr or aout)[:400]}')
time.sleep(1)
Expand Down
61 changes: 61 additions & 0 deletions test/nemoclaw-start.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,64 @@ describe("nemoclaw-start non-root fallback", () => {
}
});
});

describe("nemoclaw-start auto-pair client whitelisting (#117)", () => {
const src = fs.readFileSync(START_SCRIPT, "utf-8");

it("defines ALLOWED_CLIENTS whitelist containing openclaw-control-ui", () => {
expect(src).toMatch(/ALLOWED_CLIENTS\s*=\s*\{.*'openclaw-control-ui'.*\}/);
});

it("defines ALLOWED_MODES whitelist containing webchat", () => {
expect(src).toMatch(/ALLOWED_MODES\s*=\s*\{.*'webchat'.*\}/);
});

it("rejects devices not in the whitelist", () => {
expect(src).toMatch(/client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES/);
expect(src).toMatch(/\[auto-pair\] rejected unknown client=/);
});

it("validates device is a dict before accessing fields", () => {
expect(src).toMatch(/if not isinstance\(device, dict\)/);
});

it("logs client identity on approval", () => {
expect(src).toMatch(/\[auto-pair\] approved request=\{request_id\} client=\{client_id\}/);
});

it("does not unconditionally approve all pending devices", () => {
// The old pattern: `(device or {}).get('requestId')` β€” approve everything
// Must NOT be present in the auto-pair block
expect(src).not.toMatch(/\(device or \{\}\)\.get\('requestId'\)/);
});

it("tracks handled requests to avoid reprocessing rejected devices", () => {
expect(src).toMatch(/HANDLED\s*=\s*set\(\)/);
expect(src).toMatch(/request_id in HANDLED/);
expect(src).toMatch(/HANDLED\.add\(request_id\)/);
});

it("documents NEMOCLAW_DISABLE_DEVICE_AUTH as a build-time setting in the script header", () => {
// Must mention it's build-time only β€” setting at runtime has no effect
// because openclaw.json is baked and immutable
const header = src.split("set -euo pipefail")[0];
expect(header).toMatch(/NEMOCLAW_DISABLE_DEVICE_AUTH/);
expect(header).toMatch(/build[- ]time/i);
});

it("defines ALLOWED_CLIENTS and ALLOWED_MODES outside the poll loop", () => {
// These are constants β€” they should be defined once alongside HANDLED,
// not reconstructed inside the `if pending:` block every poll cycle
const autoPairBlock = src.match(/PYAUTOPAIR[\s\S]*?PYAUTOPAIR/);
expect(autoPairBlock).toBeTruthy();
const pyCode = autoPairBlock[0];

// ALLOWED_CLIENTS/ALLOWED_MODES should appear BEFORE the `while` loop,
// at the same level as HANDLED, APPROVED, etc.
const allowedClientsPos = pyCode.indexOf("ALLOWED_CLIENTS");
const whilePos = pyCode.indexOf("while time.time()");
expect(allowedClientsPos).toBeGreaterThan(-1);
expect(whilePos).toBeGreaterThan(-1);
expect(allowedClientsPos).toBeLessThan(whilePos);
});
});
68 changes: 68 additions & 0 deletions test/security-c2-dockerfile-injection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,71 @@ describe("C-2 regression: Dockerfile must not interpolate build-args into Python
expect(hasEnvRead).toBeTruthy();
});
});

// ═══════════════════════════════════════════════════════════════════
// 4. Gateway auth hardening β€” no hardcoded insecure defaults (#117)
// ═══════════════════════════════════════════════════════════════════
describe("Gateway auth hardening: Dockerfile must not hardcode insecure auth defaults", () => {
it("dangerouslyDisableDeviceAuth is not hardcoded to True", () => {
const src = fs.readFileSync(DOCKERFILE, "utf-8");
// Must not contain a literal `'dangerouslyDisableDeviceAuth': True`
expect(src).not.toMatch(/'dangerouslyDisableDeviceAuth':\s*True/);
});

it("allowInsecureAuth is not hardcoded to True", () => {
const src = fs.readFileSync(DOCKERFILE, "utf-8");
// Must not contain a literal `'allowInsecureAuth': True`
expect(src).not.toMatch(/'allowInsecureAuth':\s*True/);
});

it("dangerouslyDisableDeviceAuth is derived from NEMOCLAW_DISABLE_DEVICE_AUTH env var", () => {
const src = fs.readFileSync(DOCKERFILE, "utf-8");
// The Python config generation must read the env var
expect(src).toMatch(/os\.environ\.get\(['"]NEMOCLAW_DISABLE_DEVICE_AUTH['"]/);
// And use the derived variable in the config dict
expect(src).toMatch(/'dangerouslyDisableDeviceAuth':\s*disable_device_auth/);
});

it("allowInsecureAuth is derived from URL scheme (explicit http allowlist)", () => {
const src = fs.readFileSync(DOCKERFILE, "utf-8");
// Must use explicit 'http' allowlist β€” not `!= 'https'` which would allow
// insecure auth for malformed or unknown schemes (CodeRabbit review on #123)
expect(src).toMatch(/allow_insecure\s*=\s*parsed\.scheme\s*==\s*'http'/);
expect(src).not.toMatch(/allow_insecure\s*=\s*parsed\.scheme\s*!=\s*'https'/);
// And use the derived variable in the config dict
expect(src).toMatch(/'allowInsecureAuth':\s*allow_insecure/);
});

it("NEMOCLAW_DISABLE_DEVICE_AUTH defaults to '0' (secure by default)", () => {
const src = fs.readFileSync(DOCKERFILE, "utf-8");
expect(src).toMatch(/ARG\s+NEMOCLAW_DISABLE_DEVICE_AUTH=0/);
});

it("NEMOCLAW_DISABLE_DEVICE_AUTH is promoted to ENV before the Python RUN layer", () => {
const src = fs.readFileSync(DOCKERFILE, "utf-8");
const lines = src.split("\n");
let promoted = false;
let inEnvBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^\s*FROM\b/.test(line)) {
promoted = false;
inEnvBlock = false;
}
if (/^\s*ENV\b/.test(line)) {
inEnvBlock = true;
}
if (inEnvBlock && /NEMOCLAW_DISABLE_DEVICE_AUTH[=\s]/.test(line)) {
promoted = true;
}
if (inEnvBlock && !/\\\s*$/.test(line)) {
inEnvBlock = false;
}
if (/^\s*RUN\b.*python3\s+-c\b/.test(line)) {
expect(promoted).toBeTruthy();
return;
}
}
expect(promoted).toBeTruthy();
});
});
Loading