Skip to content

security: add loopback origin check to /system/set-env#81

Merged
debpalash merged 4 commits into
mainfrom
security/loopback-set-env
May 18, 2026
Merged

security: add loopback origin check to /system/set-env#81
debpalash merged 4 commits into
mainfrom
security/loopback-set-env

Conversation

@debpalash
Copy link
Copy Markdown
Owner

@debpalash debpalash commented May 18, 2026

Summary

  • Adds a request.client.host allow-list check (127.0.0.1 / ::1 / localhost) to POST /system/set-env so non-loopback callers receive 403 instead of being able to mutate os.environ for HF_TOKEN / TRANSLATE_API_KEY.
  • Surfaced during security review of PR l10n(zh-CN): full Chinese localization + Windows compat + backend fixes #66 — that PR widens the existing pre-fix window (in-memory state) into disk-persistent secret overwrite via the expanded PERSISTENT_KEYS allow-list. This PR closes the underlying vulnerability so PR l10n(zh-CN): full Chinese localization + Windows compat + backend fixes #66's revision lands onto a clean base.
  • Defensive request.client is None branch handles ASGI middleware that strips client info (rare but documented in Starlette).

Scope

File Lines Note
backend/api/routers/system.py +5 / -1 Request import + signature + 3-line loopback guard
tests/test_api.py +63 / -0 3 new tests (reject non-loopback / allow loopback / allow-list still validated on loopback)
.planning/quick/260518-ivy-.../ +planning artifacts GSD trail: PLAN, SUMMARY, deferred-items
.planning/STATE.md +6 / -1 Quick task entry

3 of 4 commits are pure planning/process artifacts; the one functional change is e1f08a6. CI matrix from #71 should validate cross-platform.

Test plan

  • uv run python -m pytest tests/test_api.py -k set_env -x -q → 3 passed
  • Non-regression: uv run python -m pytest tests/test_router_smoke.py → 23 passed
  • Phase 0 CI matrix green on macOS / Windows / Linux
  • Manual verification: confirm a LAN-bound POST to http://<lan-ip>:3900/system/set-env returns 403 (was previously 200 + state mutation)

Follow-up

260518-ivy-deferred-items.md enumerates the 5 sibling POST routes in system.py that share the same auth/origin gap pattern. Task tracker has these queued for a broader /system/* audit (separate PR, not bundled here to keep this surface tight).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Hardened /system/set-env endpoint with origin validation to prevent LAN-based credential overwrite attacks. Non-loopback requests now return HTTP 403 and are rejected; loopback requests continue to work as expected.
  • Tests

    • Added test coverage for loopback origin enforcement, non-loopback rejection, and validation of existing allowlist restrictions.

Review Change Stack

debpalash and others added 4 commits May 18, 2026 13:39
Surfaced during PR #66 security review. Closes a local-LAN
credential-overwrite vector — the endpoint mutates os.environ for
HF_TOKEN / TRANSLATE_API_KEY and the backend binds 0.0.0.0:3900, so any
LAN host or arbitrary local process could overwrite stored tokens. The
vector existed pre-PR for in-memory state and was widened by PR #66's
prefs.json persistence.

Implementation: gate /system/set-env on request.client.host being one
of ("127.0.0.1", "::1", "localhost"); raise HTTPException(403,
"set-env requires loopback origin") otherwise. The check is the first
statement of the handler so the existing allow-list, os.environ
mutation, logger, and return-shape behaviour are preserved verbatim
for the loopback path. Reads the actual TCP peer address (not any
X-Forwarded-For header) so the guard is not spoofable.

Tests: three regression tests in tests/test_api.py cover the 403
(default TestClient host == "testclient"), 200 (TestClient with
client=("127.0.0.1", 50000)), and 400 (loopback origin still respects
the ALLOWED_KEYS allow-list) paths.

Notes: other POST endpoints in backend/api/routers/system.py share the
same gap and are catalogued in
.planning/quick/260518-ivy-add-loopback-origin-check-to-system-set-/260518-ivy-deferred-items.md
for follow-up (Task #18).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaced during PR #66 security review. Closes a local-LAN credential-
overwrite vector that existed pre-PR for in-memory state and would have
been widened by PR #66's prefs.json persistence to disk.

Companion to commit e1f08a6 (the code fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 18, 2026

📝 Walkthrough

Walkthrough

This PR adds a loopback-origin check to the /system/set-env endpoint in FastAPI to block unauthenticated credential overwrites from non-loopback addresses. The implementation updates the handler signature to accept a Request object, extracts the client host, and rejects non-loopback requests with HTTP 403 before environment mutation occurs. Tests validate loopback acceptance, non-loopback rejection, and preservation of the existing allow-list validation.

Changes

Loopback origin guard for /system/set-env

Layer / File(s) Summary
Loopback origin guard implementation
backend/api/routers/system.py, tests/test_api.py
Request import added to handler signature. Handler updated to extract request.client.host and return HTTP 403 for non-loopback addresses (127.0.0.1, ::1, localhost) before any environment mutation. Existing allow-list validation for HF_TOKEN and TRANSLATE_API_KEY preserved. Three regression tests added: non-loopback rejection with no env mutation, loopback acceptance with JSON response, and loopback allow-list enforcement.
Task execution plan and completion artifacts
.planning/STATE.md, .planning/quick/260518-ivy-*/260518-ivy-PLAN.md, .planning/quick/260518-ivy-*/260518-ivy-SUMMARY.md, .planning/quick/260518-ivy-*/260518-ivy-deferred-items.md
Planning documents record task metadata, TDD/commit/deferred-items procedures, threat-model STRIDE coverage, test verification results, commit SHA and diff, and deferred-items audit enumerating five other POST endpoints out of scope for this task requiring follow-up triage.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 A loopback guard now shields the env,
No LAN intruders shall pass this zen,
Request inspected, localhost blessed,
Credentials safe, the rabbits rest! 🔐

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: adding a loopback origin check to the /system/set-env endpoint to address a security vulnerability.
Description check ✅ Passed The description covers the summary, changes, and test plan comprehensively. However, it does not follow the repository's template structure (missing Type checkbox, incomplete Checklist section, and optional sections like Screenshots).
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/loopback-set-env

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
@.planning/quick/260518-ivy-add-loopback-origin-check-to-system-set-/260518-ivy-PLAN.md:
- Line 239: The markdown table right before the closing </threat_model> tag
isn't terminated properly; insert a blank line between the last STRIDE table row
and the </threat_model> tag so the table is closed correctly (i.e., ensure
there's an empty line after the final STRIDE table row and before
</threat_model> to satisfy markdownlint).

In
@.planning/quick/260518-ivy-add-loopback-origin-check-to-system-set-/260518-ivy-SUMMARY.md:
- Around line 72-83: Add explicit fenced-code languages (e.g., bash) to both
markdown code blocks containing the pytest commands: the block with the line 'uv
run python -m pytest tests/test_api.py -k "set_env" -x -q' and the block with
'uv run python -m pytest tests/test_router_smoke.py -x -q' should start with
```bash instead of ``` so markdownlint MD040 is satisfied.

In @.planning/STATE.md:
- Line 3: Update the footer timestamp in .planning/STATE.md so it matches the
header "Last updated" date (change the footer date from 2026-05-16 to
2026-05-18) to ensure the file uses a single canonical last-updated date; edit
the footer line containing the older date to the new date shown in the header.

In `@tests/test_api.py`:
- Around line 588-600: The test test_set_env_loopback_still_validates_allowlist
assumes DISALLOWED is unset; preserve and restore any pre-existing value by
capturing original = os.environ.get("DISALLOWED") at the start, run the test
(ensuring you import os), and in a finally block restore os.environ: if original
is None remove os.environ["DISALLOWED"] if present else set it back to original;
keep the same assertions against the TestClient POST to "/system/set-env" so the
test is isolated and won't fail due to environment leakage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 92a53a4a-fcf5-4be5-89da-46dc9f08426e

📥 Commits

Reviewing files that changed from the base of the PR and between 766e2f7 and 457aefa.

📒 Files selected for processing (6)
  • .planning/STATE.md
  • .planning/quick/260518-ivy-add-loopback-origin-check-to-system-set-/260518-ivy-PLAN.md
  • .planning/quick/260518-ivy-add-loopback-origin-check-to-system-set-/260518-ivy-SUMMARY.md
  • .planning/quick/260518-ivy-add-loopback-origin-check-to-system-set-/260518-ivy-deferred-items.md
  • backend/api/routers/system.py
  • tests/test_api.py

| T-ivy-04 | Tampering | Local non-OmniVoice process on loopback still able to call /system/set-env | accept | Out of scope for this quick fix. OS-level UID/process auth would be required; defer to a future hardening pass. Same-machine processes already share write access to $HF_HOME/token under the local-first model. |
| T-ivy-05 | Spoofing | `X-Forwarded-For` header tricking the guard | mitigate-by-design | Guard reads `request.client.host` (the actual TCP peer), NOT any header. Documented in test rationale. |
| T-ivy-SC | Tampering | npm/pip installs in this commit | mitigate | No new package installs — `fastapi.Request` is already present in the pinned dependency set. Slopcheck not required for this commit. |
</threat_model>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix markdown table termination before </threat_model>.

Add a blank line between the last STRIDE table row and </threat_model> so markdownlint doesn’t treat the tag as a broken table row.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 239-239: Table pipe style
Expected: leading_and_trailing; Actual: no_leading_or_trailing; Missing leading pipe

(MD055, table-pipe-style)


[warning] 239-239: Table pipe style
Expected: leading_and_trailing; Actual: no_leading_or_trailing; Missing trailing pipe

(MD055, table-pipe-style)


[warning] 239-239: Table column count
Expected: 5; Actual: 1; Too few cells, row will be missing data

(MD056, table-column-count)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
@.planning/quick/260518-ivy-add-loopback-origin-check-to-system-set-/260518-ivy-PLAN.md
at line 239, The markdown table right before the closing </threat_model> tag
isn't terminated properly; insert a blank line between the last STRIDE table row
and the </threat_model> tag so the table is closed correctly (i.e., ensure
there's an empty line after the final STRIDE table row and before
</threat_model> to satisfy markdownlint).

Comment on lines +72 to +83
```
$ uv run python -m pytest tests/test_api.py -k "set_env" -x -q
... [100%]
3 passed, 28 deselected in 1.36s
```

Plus the wider router smoke suite as a non-regression check:

```
$ uv run python -m pytest tests/test_router_smoke.py -x -q
23 passed, 7 warnings in 106.67s
```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add language identifiers to fenced code blocks.

Both command snippets should use explicit fence languages (e.g., bash) to satisfy markdownlint MD040.

Proposed markdown fix
-```
+```bash
 $ uv run python -m pytest tests/test_api.py -k "set_env" -x -q
 ...                                                                      [100%]
 3 passed, 28 deselected in 1.36s

@@
- +bash
$ uv run python -m pytest tests/test_router_smoke.py -x -q
23 passed, 7 warnings in 106.67s

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
$ uv run python -m pytest tests/test_api.py -k "set_env" -x -q
... [100%]
3 passed, 28 deselected in 1.36s
```
Plus the wider router smoke suite as a non-regression check:
```
$ uv run python -m pytest tests/test_router_smoke.py -x -q
23 passed, 7 warnings in 106.67s
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 72-72: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 80-80: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
@.planning/quick/260518-ivy-add-loopback-origin-check-to-system-set-/260518-ivy-SUMMARY.md
around lines 72 - 83, Add explicit fenced-code languages (e.g., bash) to both
markdown code blocks containing the pytest commands: the block with the line 'uv
run python -m pytest tests/test_api.py -k "set_env" -x -q' and the block with
'uv run python -m pytest tests/test_router_smoke.py -x -q' should start with
```bash instead of ``` so markdownlint MD040 is satisfied.

Comment thread .planning/STATE.md
# STATE: OmniVoice Studio v0.3.x Stabilization

**Last updated:** 2026-05-16
**Last updated:** 2026-05-18 — Completed quick task 260518-ivy: loopback origin check on /system/set-env
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Align the footer timestamp with the new header timestamp.

Line 3 says last updated on 2026-05-18, but the footer still says 2026-05-16. Please keep one canonical date.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.planning/STATE.md at line 3, Update the footer timestamp in
.planning/STATE.md so it matches the header "Last updated" date (change the
footer date from 2026-05-16 to 2026-05-18) to ensure the file uses a single
canonical last-updated date; edit the footer line containing the older date to
the new date shown in the header.

Comment thread tests/test_api.py
Comment on lines +588 to +600
def test_set_env_loopback_still_validates_allowlist():
"""Even on the loopback path, keys outside the allow-list must return 400 —
the new guard must NOT bypass the existing allow-list enforcement."""
from fastapi.testclient import TestClient
from main import app

loopback_client = TestClient(app, client=("127.0.0.1", 50000))
res = loopback_client.post(
"/system/set-env",
json={"key": "DISALLOWED", "value": "x"},
)
assert res.status_code == 400
assert "DISALLOWED" not in os.environ
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve pre-existing DISALLOWED env state in the allowlist test.

This test assumes DISALLOWED is absent. If it exists in the runner env, the assertion can fail for the wrong reason. Save/restore it like the HF_TOKEN tests.

Proposed test-isolation fix
 def test_set_env_loopback_still_validates_allowlist():
@@
-    loopback_client = TestClient(app, client=("127.0.0.1", 50000))
-    res = loopback_client.post(
-        "/system/set-env",
-        json={"key": "DISALLOWED", "value": "x"},
-    )
-    assert res.status_code == 400
-    assert "DISALLOWED" not in os.environ
+    loopback_client = TestClient(app, client=("127.0.0.1", 50000))
+    original = os.environ.get("DISALLOWED")
+    os.environ.pop("DISALLOWED", None)
+    try:
+        res = loopback_client.post(
+            "/system/set-env",
+            json={"key": "DISALLOWED", "value": "x"},
+        )
+        assert res.status_code == 400
+        assert "DISALLOWED" not in os.environ
+    finally:
+        if original is None:
+            os.environ.pop("DISALLOWED", None)
+        else:
+            os.environ["DISALLOWED"] = original
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_api.py` around lines 588 - 600, The test
test_set_env_loopback_still_validates_allowlist assumes DISALLOWED is unset;
preserve and restore any pre-existing value by capturing original =
os.environ.get("DISALLOWED") at the start, run the test (ensuring you import
os), and in a finally block restore os.environ: if original is None remove
os.environ["DISALLOWED"] if present else set it back to original; keep the same
assertions against the TestClient POST to "/system/set-env" so the test is
isolated and won't fail due to environment leakage.

@debpalash debpalash merged commit 21c338c into main May 18, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant