Skip to content

fix(auth): API key bypass via percent-encoded paths - #636

Merged
ErikBjare merged 2 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/apikey-percent-encoding-bypass
Jul 28, 2026
Merged

ErikBjare merged 2 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/apikey-percent-encoding-bypass

Conversation

@TimeToBuildBob

@TimeToBuildBob TimeToBuildBob commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

The api_key auth gate (added in #585, closing #494) can be bypassed completely — read and write — with a single curl and no credentials:

curl http://127.0.0.1:5666/api/0/buckets/     -> 401 Missing or invalid API key
curl http://127.0.0.1:5666/%61pi/0/info       -> 200, full server info, no auth header
curl -X POST http://127.0.0.1:5666/%61pi/0/buckets/poc -d '{...}'  -> 200, bucket created

Root cause

ApiKeyCheck::on_request compared the raw, still-percent-encoded path:

let path = request.uri().path().as_str();
let normalized_path = format!("/{}", path.trim_start_matches('/'));
if !normalized_path.starts_with("/api/") { return; }   // sees "/%61pi/..." -> skips auth

Rocket's router matches on the decoded segment view instead — rocket::router::collider::paths_match uses req.uri().path().segments(), which percent-decodes and skips empty segments. So the gate and the router disagreed about what the path is: the gate saw /%61pi/0/buckets/ (not an API path, skip auth), the router saw api / 0 / buckets and dispatched to the real handler.

This is the same desync as the //api/... bypass fixed in #588. That fix patched one spelling of the disagreement (leading slashes) while leaving the disagreement itself in place — which is why a second variant surfaced. Reasoning about encodings one at a time invites a third (double encoding, unicode tricks, ...).

Fix

Match on exactly the view the router dispatches on:

let segments: Vec<&str> = request.uri().path().segments().collect();
if segments.first() != Some(&API_SEGMENT) { return; }
if PUBLIC_PATHS.contains(&segments.as_slice()) { return; }

Percent-decoding and empty-segment skipping now happen in the same code path for gate and router, so double slashes, encoded characters, and double encoding all resolve identically on both sides. This subsumes the #588 fix rather than layering on it (its regression test is kept and still passes). Public-path exemptions move to decoded segment slices for the same reason.

Tests

Three regression tests added. All three fail against the pre-fix gate and pass after — verified by reverting the gate logic locally and re-running:

test_api_key_percent_encoded_paths_require_auth ... FAILED   <- pre-fix
  assertion `left != right` failed: auth bypassed via encoded path /%61pi/0/buckets/
test_api_key_percent_encoded_write_requires_auth ... FAILED  <- pre-fix
  assertion `left != right` failed: write bypassed via encoded path
test_public_path_matched_on_decoded_segments ... FAILED      <- pre-fix

Coverage: encoded first char, fully encoded segment, mid-segment encoding, encoding combined with the #588 double-slash trick, POST/write bypass (plus confirming the bucket was not created), and decoded-segment parity for the /api/0/info public exemption.

cargo test -p aw-server green (32 tests), cargo fmt --check clean, no new clippy warnings.

Impact

api_key was added specifically so other local processes/users can't reach the API (refs ActivityWatch/activitywatch#32, ActivityWatch/activitywatch#1199). This bug defeated exactly that threat model, so I'd agree with the reporter's critical rating — full read/write with no tooling and no credentials.

Reported by pal0x, who also flagged that the right fix here is structural rather than per-encoding. Agreed, and that's what this does.

The ApiKeyCheck fairing compared the raw, still-percent-encoded request
path against "/api/", while Rocket's router matches on the percent-decoded
segment view (rocket::router::collider::paths_match uses
req.uri().path().segments()). A request to /%61pi/0/buckets/ therefore
failed the fairing's prefix check — skipping auth entirely — and was still
dispatched by the router to the real /api/0/buckets/ handler. Full read and
write bypass with a single curl and no credentials.

This is the same underlying desync as the //api/... bypass fixed in ActivityWatch#588:
gate and router disagreed on what the path is. ActivityWatch#588 patched one spelling
(leading slashes); this patches the class, by matching on exactly the
segment view the router dispatches on. Empty segments are skipped and
percent-decoding is applied by the same code path, so double slashes,
encoded characters, and double encoding all resolve identically on both
sides.

Public-path exemptions move to decoded segment slices for the same reason.

Reported by Judel <security@pal0x.dev>.
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Verified end-to-end over real HTTP (not just the in-process test client), both directions, against a server started with api_key = "secret-poc-key" in config-testing.toml.

Unpatched master (59eaae3) — reporter's PoC reproduces exactly:

GET  /api/0/buckets/                 -> 401   (honest path correctly gated)
GET  /%61pi/0/info                   -> 200   bypass
GET  /%61pi/0/buckets/               -> 200   bypass
POST /%61pi/0/buckets/authbypass-poc -> 200   bypass, bucket created

Bucket listing afterwards confirms the write landed with no credentials:

{"authbypass-poc":{"id":"authbypass-poc","type":"test","client":"authbypass-poc",...}}

This branch:

GET  /api/0/buckets/                 -> 401
GET  /%61pi/0/buckets/               -> 401
GET  //api/0/buckets/                -> 401   (#588 case, still covered)
GET  /ap%69/0/buckets/               -> 401
GET  /%61%70%69/0/buckets/           -> 401
POST /%61pi/0/buckets/authbypass-poc -> 401
GET  /api/0/buckets/  + valid key    -> 200   (not over-blocking)
GET  /api/0/info                     -> 200   (public exemption intact)
GET  /%61pi/0/info                   -> 200   (public exemption, decoded — router parity)

Bucket listing afterwards: {} — the POC write was refused.

Worth noting the last line: pre-fix, /%61pi/0/info was also mismatched, just in the harmless direction (gate 401'd a request the router would have sent to the public info handler). Gate and router now agree in both directions, which is the actual invariant.

Comment thread aw-server/src/endpoints/apikey.rs Outdated
@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown

Greptile Summary

The API-key fairing now uses Rocket’s decoded path-segment representation for authorization decisions.

  • Matches protected API routes and public-path exemptions using decoded segments.
  • Adds regression coverage for encoded read and write paths, double slashes, route reachability, and public-path matching.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
aw-server/src/endpoints/apikey.rs Aligns authentication matching with Rocket routing and strengthens regression assertions so encoded paths must receive explicit authentication outcomes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[Incoming request] --> B[Decode path into Rocket segments]
    B --> C{First segment is api?}
    C -->|No| D[Continue without API-key gate]
    C -->|Yes| E{Exact public path?}
    E -->|Yes| D
    E -->|No| F{Valid bearer API key?}
    F -->|Yes| G[Dispatch API handler]
    F -->|No| H[Return 401 Unauthorized]
Loading

Reviews (2): Last reviewed commit: "test(auth): assert exact 401 on encoded-..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 75.77%. Comparing base (656f3c9) to head (2003e5a).
⚠️ Report is 78 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #636      +/-   ##
==========================================
+ Coverage   70.81%   75.77%   +4.96%     
==========================================
  Files          51       62      +11     
  Lines        2916     5041    +2125     
==========================================
+ Hits         2065     3820    +1755     
- Misses        851     1221     +370     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Greptile P2: the encoded-path tests asserted "not 200", which a 404 also
satisfies — a route miss would have passed as an auth success.

- Assert Status::Unauthorized specifically for paths that decode to real
  /api/ routes.
- Add a positive control: the same encoded paths return 200 with a valid
  key, proving the 401s come from the gate rather than the router failing
  to match.
- /%41PI/ decodes to /API/, which is not a registered route; split it out
  with an explicit 404 assertion so a future case-insensitive routing
  change surfaces here instead of silently passing.
- Tighten the post-write bucket check and the public-path test to exact
  statuses for the same reason.

Re-verified all three regression tests still fail against the pre-fix gate.
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Good catch on the P2 — fixed in 2003e5a.

assert_ne!(status, Ok) would indeed have been satisfied by a 404, so a route miss could have passed as an auth success. The encoded-path cases now assert Status::Unauthorized exactly, and I added a positive control: the same encoded paths return 200 with a valid key, which is what actually proves the 401s come from the gate rather than from the router failing to match.

/%41PI/0/buckets/ was the specific case you flagged — it decodes to /API/, which isn't a registered route, so it 404s at the router and never exercises the gate. Split it out with an explicit Status::NotFound assertion and a comment, so it still documents that routing is case-sensitive (a future case-insensitive change would fail here) without masquerading as an auth test.

Also tightened the post-write bucket lookup and the public-path test to exact statuses for the same reason. Re-verified all three regression tests still fail against the pre-fix gate.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

CI-green and mergeable (Greptile 5/5) — waiting only on a maintainer click.

This PR is ready to merge, but the bot has pull-only access to this repo and can't self-merge — surfacing it here so it isn't lost. The monitoring loop will stop re-flagging it now that this note is posted.

@ErikBjare
ErikBjare merged commit 8fd4e09 into ActivityWatch:master Jul 28, 2026
8 checks passed
ErikBjare pushed a commit that referenced this pull request Jul 28, 2026
…ts (#637)

* fix(cors): scope the moz-extension wildcard to aw-watcher-web endpoints

Both servers trust every Firefox extension origin unconditionally
(`moz-extension://.*`), because extension IDs are random per install and
cannot be allowlisted up front. The consequence is that any installed
extension — with no host permission, so no install-time prompt naming
ActivityWatch — can read `/api/0/export` and write events.

Deleting the wildcard does not work: host permissions do NOT exempt a
Firefox background script from CORS. Verified in Firefox 152 with
permissions granted at runtime and an ACAO-only A/B against a mock server;
the server's Access-Control-Allow-Origin header is the sole gate. Removing
it silently breaks aw-watcher-web.

So narrow it instead. Wildcard-matched extension origins now reach only the
three endpoints aw-watcher-web actually uses:

  GET  /api/0/info
  POST /api/0/buckets/<id>
  POST /api/0/buckets/<id>/heartbeat

Everything else — export, import, query, settings, event reads, bucket
deletion — is closed to them. That removes the exfiltration half of the
problem (app names, window titles, tab URLs, AFK data) with no client
change. Origins the user allowlists explicitly via `cors_regex` keep full
access.

Enforcement is two-stage, because CORS alone only hides responses: blocked
requests are rejected with 403 before reaching a handler (a "simple" POST is
sent by the browser regardless and would otherwise still execute), and the
Access-Control-* headers are stripped so preflights fail.

Path matching uses the decoded segment view the router dispatches on, like
the #636 fix — a raw-string check would let /%61pi/0/export desynchronize
this gate from the handler it protects.

Verified in Firefox 152 with an MV2 extension declaring only
`permissions: ["storage"]` (host_permissions=[]), against two instances of
this build — one with the wildcard opt-in (pre-fix behaviour), one scoped:

  unscoped.export=200        scoped.export=CORS_ERR
  unscoped.import=200        scoped.import=CORS_ERR
  unscoped.ensureBucket=200  scoped.ensureBucket=200
  unscoped.heartbeat=200     scoped.heartbeat=200

Reported privately by Judel (security@pal0x.dev).

* fix(cors): restrict extension heartbeats to web buckets

* docs(cors): disclose residual web-bucket poisoning
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.

2 participants