Repository navigation
fix(unifia): harden security and restore E2E coverage - #29
Conversation
CodeQL js/polynomial-redos (#278) reports `replace(/\/+$/, "")` in remote-connect.tsx. The anchored `+` makes the engine retry from every offset when the slash run never reaches `$`, so the cost is quadratic in a length the user chooses by pasting. Measured on Bun 1.3.14 with `"https://h/" + "/".repeat(n) + "x"`: n regex backward walk 10 000 222 ms 0.004 ms 50 000 2 914 ms 0.004 ms 100 000 8 001 ms 0.004 ms 8 s on the main thread is a frozen WebView, so this is a real defect and not only a rule firing. The alert named one site; the pattern is the class. Every place that normalizes a URL the user typed carries it: - app context/server.tsx normalizeServerUrl, serverName - app connect/remote-connect.tsx - mobile entry.tsx a verbatim copy of normalizeServerUrl - mobile notifications.ts NotificationBridge's constructor `trimTrailingSlashes` now lives in packages/app/src/utils/url.ts and is exported from @unifia/app, and mobile's inline duplicate of normalizeServerUrl calls the shared function instead of repeating its two lines — the duplicate is how the fix would have had to be found twice. The remaining `replace(/\/+$/, "")` occurrences normalize filesystem paths and config keys (file/index.ts, worker-runtime.ts, autocomplete, dialog-select-directory). They are not reachable from a pasted string of attacker-chosen length and are left alone. Test: packages/app/src/utils/url.test.ts asserts equivalence with the regex it replaces and bounds the 100 000-slash case to 100 ms.
Two CodeQL findings, one root shape: a write that assumes it is creating the file it is about to harden, without saying so to the kernel. js/file-system-race (#334) — team/worktree-manager.ts `existsSync(marker)` then `writeFileSync(marker)` is a TOCTOU, and this module exists precisely so several workers can act on worktrees at once: two of them both observe "absent" and both write, and the second silently overwrites a marker the first is already being read for. Replaced by the `wx` flag (O_CREAT|O_EXCL) so the kernel picks the winner in one call. EEXIST is the idempotent path and the only error swallowed. js/http-to-file-access (#333) — github/auth.ts The taint the rule follows — a GitHub HTTP response reaching a file write — is not exploitable: the destination is the module constant on line 22, never the network data, and the content round-trips through JSON.parse. Reported as such rather than closed; that call is Erwan's. But the flagged line held a real defect. `mode` on writeFile only applies when writeFile creates the file, and the temp path was the fixed `github-auth.json.tmp`. Left behind by a crash — or planted by anything that can write the data dir — it was reused as-is and the OAuth access token was persisted with whatever permissions it already had. The write now uses a unique name and `wx`, so `mode: 0o600` is always the mode of the file that receives the token. Tests - worktree-manager.test.ts: creation, no-clobber, and a POSIX-only case proving `wx` refuses to write through a planted symlink. That last one is skipped on Windows on measurement, not on principle — CreateFile with CREATE_NEW follows the reparse point and creates the target there, so the flag buys nothing and the assertion would fail for an unrelated reason. - github/auth.test.ts: a stale 0644 `.tmp` is left untouched and the persisted session is 0600. Verified decisive — reverting only auth.ts fails it with ENOENT on the stale file (the old code truncated and renamed it away).
…justified it The e2e runner defaulted UNIFIA_DISABLE_LSP to "true" on the stated grounds that language servers were "the mechanism behind the suite's flakiness". That claim was never measured, and measurement refutes it: three runs of the same two tests give LSP on -> 2 passed (43.7 s), LSP off -> 2 failed, LSP off -> 2 passed (37.3 s). The suite flakes either way. What the flag does buy is real — four 45 s `initialize` timeouts and every `spawned lsp server` line disappear — but that is a cost removed, not a cause. Left on by default it would have hidden any LSP regression from the suite permanently, in exchange for a shorter run. The flag itself stays: it is a legitimate opt-in, it has four tests in test/flag/disable-lsp.test.ts, and its gate sits at the same point as `cfg.lsp === false` (lsp/index.ts:303). It is simply no longer defaulted — `serverEnv` already spreads process.env, so setting it in the environment still works. The same false claim was duplicated in the flag's own doc comment; it is corrected there too, with an explicit "do not reach for this to stabilise a test". Nothing in .github/workflows sets UNIFIA_DISABLE_LSP, so CI picks the language servers back up from this change alone.
|
Hey! Your PR title Please update it to start with one of:
Where See CONTRIBUTING.md for details. |
Root cause of the prompt-family failures, read off the Playwright call log of
run 31480610511 rather than inferred:
- waiting for getByRole('button', { name: 'Send' }).first()
- attempting click action
- <div data-slot="toast-description">Choose an agent and model before
sending a prompt.</div> ... intercepts pointer events
208 x retrying click action
`submit.ts:306` refuses the prompt outright when either the agent or the model
is missing — it shows that toast and returns, so nothing is sent and no request
ever reaches the mock LLM. The helper in fixtures.ts typed and pressed Enter as
soon as the prompt input was *visible*, but agent and model are resolved from
the provider list the backend serves, which lands after the input renders.
Typing before then is the race. That is the exact trap e2e/AGENTS.md names:
"Do not treat a visible element as proof that the app will route the next
action to it."
Everything else in those failures is cascade. `started` never moves, so the
helper falls back to clicking Send; the click is intercepted; the test burns
its 120 s timeout; the queued mock response is never consumed and the `llm`
fixture teardown reports "TestLLMServer still has 1 queued response(s)". Four
of the twelve common failures were that teardown message — a symptom reported
as if it were the defect.
`waitPromptReady` polls the committed `__opencode_e2e.model.current` probe for
both `agent` and `model`, which is the same state submit.ts checks. Semantic
app state, no wall-clock wait, no new probe: the one it needs already existed
and was already used by the noReply branch two functions above.
Second defect, independent and also from the same call log: the debug bar
`<aside aria-label="Development performance diagnostics">` is `fixed bottom-3
right-3 z-50 pointer-events-auto` and 324 px wide, which parks it on top of the
composer's Send button. That is what the 208 retries were hitting once the
toast faded. It is dev-only telemetry, never shipped, and nothing in the suite
exercises it, so layout.tsx no longer renders it when the e2e harness is
present. Without this the fallback click can never land, and a fast, legible
failure becomes a timeout.
`e2eActive()` reads the `__opencode_e2e` marker the harness installs from an
init script. Nothing else writes it, so a normal page always reads false and
the bar is unchanged for developers.
This is the first causal defect behind the prompt-family failures, and it is
the repo's signature shape: a producer whose consumer was never wired to it.
`fixtures.ts` derived the model it seeds into the browser from
OPENCODE_E2E_MODEL, defaulting to `opencode/gpt-5-nano`. That variable is set
by `script/e2e-local.ts` for `seed-e2e.ts`, which writes a message record on
the *shared* backend. The browser does not talk to that backend — it talks to
the isolated per-worker backend started by `e2e/backend.ts`. Asked what it
serves:
HTTP 200
provider=local-llm models=1 gemma-4-E4B-it
One provider. No `opencode`, no `gpt-5-nano`: the Zen provider is dropped for
having zero models without credentials, which is the
"provider removed: zero models after filtering" warning printed on every test.
So the composer was seeded with a model that does not exist in the list it
reads, `local.model.current()` stayed undefined, and `submit.ts:306` refused
every prompt before sending it. Which model it is does not matter for routing —
with OPENCODE_E2E_LLM_URL set, `provider.ts:797` sends every model to the mock
regardless of provider. What matters is only that the id is in the list.
The seed is now resolved from `/config/providers` on the backend the browser
will use, once per worker. One authoritative source instead of a constant that
had drifted away from the thing it was meant to describe. OPENCODE_E2E_MODEL is
still honoured when the backend actually serves it, so pointing the suite at a
specific model keeps working; `seedStorage` now requires a model rather than
defaulting to one, so this cannot silently drift again.
Measured before this branch, on the base commit 8c3e08f (run 31495152621):
13 failures / 119, after 3 attempts each.
`unit (windows)` went red on run 31501895233 with one failure: `sanitizer-fuzz.test.ts:51`, the per-iteration wall-clock assertion. The same file passes in isolation on the same machine — twice, 45.4 s cold and 4.3 s warm, 6 pass — and the base commit's own `unit (windows)` was green. Nothing in this branch touches the sanitizer. That is contention, and the test was built to catch it: 120 iterations each asserted against a 200 ms ceiling gives 120 chances per run for a GC pause or a preemption on a shared runner to fail a check that proves nothing about the code under test. The budget exists to catch catastrophic backtracking, which blows up by orders of magnitude — seconds on a 4 KB input. So the threshold only has to sit clearly above scheduler noise and clearly below a blow-up, and 200 ms sat in the noise. The per-iteration ceiling moves to ReDoS scale (2 s) and a total budget across the 120 iterations is added next to it. One stalled iteration now trips neither; a single pathological input still trips the ceiling, and a sanitizer that got slow across the board still trips the total. Strictly more sensitive to the thing being tested, and no longer sensitive to the runner. Propagation grep: the sibling budgets in `sanitizer.test.ts` (50 ms / 200 ms) are single-shot, not loops, so they carry 1/120th of the exposure and have not been observed failing. Left alone rather than churned without evidence, and reported.
…ile diffs A literal NUL in CONTROL_CHARS made git classify this file as binary. Every diff on it showed as `0 insertions, 0 deletions` and could not be reviewed — including the budget change in the previous commit. Same six code points, written as \u escapes: identical values, identical behaviour, reviewable diff.
…rdicts G3: the LSP hypothesis is refuted and the real cause named — the browser was seeded with a model the isolated e2e backend does not serve, so submit.ts:306 refused every prompt. Common-failure count corrected from 11 to 12. G4: the four remaining alerts are classified with cited evidence, two fixed at source, one already fixed on the branch, and js/http-to-file-access left open for a human decision. None closed by the agent. G7: the build chain is proven — unsigned APK produced and apksigner confirms no key was used — so only the keystore blocks it. G1: figures re-measured on this branch, including the packages/mobile trap where bun test and bun run test disagree by 31 failures.
|
This pull request has been automatically closed because it was not updated to meet our contributing guidelines within the 2-hour window. Feel free to open a new pull request that follows our guidelines. |
|
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
1 similar comment
|
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
Issue for this PR
Closes #30
Type of change
What does this PR do?
Closes the security and E2E production-readiness gates for the Unifia rebrand branch.
How did you verify your code works?
packages/app: typecheck and unit tests passed.packages/unifia: typecheck and unit tests passed.packages/mobile: typecheck passed.git diff --checkpassed.Screenshots / recordings
Not a visual UI change.
Checklist