Skip to content

Accept a list item via ?item= query parameter when the URI path is empty - #2953

Open
DL6ER wants to merge 6 commits into
developmentfrom
fix/list-item-query-fallback
Open

Accept a list item via ?item= query parameter when the URI path is empty#2953
DL6ER wants to merge 6 commits into
developmentfrom
fix/list-item-query-fallback

Conversation

@DL6ER

@DL6ER DL6ER commented Jul 12, 2026

Copy link
Copy Markdown
Member

What does this implement/fix?

A domain, regex, or group whose value is literally . or .. cannot be edited or deleted from the web UI. Per the WHATWG URL standard, a path segment equal to ./%2e (single-dot) or ../%2e%2e (double-dot) is removed while the browser resolves the URL, so such an item is stripped from every XMLHttpRequest/fetch/ajax request before it is ever sent. Percent-encoding the dot does not help either, as %2e is normalized identically to . (this is why pi-hole/web#3746 had no effect and was closed).

Adding (POST) and batch deletion (POST /api/domains:batchDelete) already carry the item in the JSON body and are unaffected. Only PUT (edit) and the non-batch DELETE read the item from the URI path, so those two operations are impossible for . and ...

This adds a backward-compatible fallback in api_list(): when the routed path leaves api->item empty, we consult an ?item=... query parameter, which is not subject to path dot-segment normalization. Normal items keep using the path unchanged and the query value is only read when the path item is empty, so no existing client changes behavior. The fallback sits before the method dispatch, so it covers GET/PUT/DELETE uniformly for every list type (groups, clients, lists, domains), and it mirrors the existing ?type= handling a few lines above.

Unlike a client-side delete-and-re-add workaround, this preserves id and date_added (a true in-place update).

The second commit fixes a defect this one exposes: get_string_var() decoded query values a second time although mg_get_var() had already decoded them. ?item=a%2541b became aAb, and a value with a space or + read as absent. Harmless while nothing compared the two forms - but we do now, and a group named with a % in it was rejected with a 400.

How to test the change during review

Create a group named . in the web UI, change its comment, then delete it again - both were impossible before. The same works via the API alone:

curl -X PUT  '.../api/groups?item=.' -d '{"comment":"dot","enabled":true}'
curl -X DELETE '.../api/groups?item=.'

Automated: test/api/test_m_mutations.py covers ?item= alone, path and query in agreement, the rejected conflict, plus a group name containing % and one containing a space.

Additional information

Related issue or feature (if applicable): Fixes pi-hole/web#3308

Pull request in docs with documentation (if applicable): N/A

Accompanying web change: pi-hole/web#3818. The two PRs should be merged together.


By submitting this pull request, I confirm the following:

  1. I have read and understood the contributors guide, as well as this entire template. I understand which branch to base my commits and Pull Requests against.
  2. I have commented my proposed changes within the code.
  3. I am willing to help maintain this change if there are issues with it later.
  4. It is compatible with the EUPL 1.2 license
  5. I have squashed any insignificant commits. (git rebase)
  6. My change does not modify src/dnsmasq/. That tree is a verbatim copy of upstream dnsmasq and we do not carry anything in it that deviates from upstream. Fixes have to go through the dnsmasq-discuss mailing list first, we merge them once they are in dnsmasq master.

Checklist:

  • The code change is tested and works locally.
  • I based my code and PRs against the repository's development branch.
  • I signed off all commits. Pi-hole enforces the DCO for all contributions
  • I signed all my commits. Pi-hole requires signatures to verify authorship
  • I have read the above and my PR is ready for review.

@DL6ER
DL6ER requested a review from a team as a code owner July 12, 2026 13:10
Copilot AI review requested due to automatic review settings July 12, 2026 13:10

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread src/api/list.c Fixed
…empty

A domain, regex, or group whose value is literally `.` or `..` cannot be
edited or deleted from the web UI. Per the WHATWG URL standard a path
segment equal to `.`/`%2e` (single-dot) or `..`/`%2e%2e` (double-dot) is
removed while the browser resolves the URL, so such an item is stripped
from every request before it is sent. Percent-encoding the dot does not
help, as `%2e` is normalized identically to `.`.

The item cannot travel as a path component in these cases, so the web
identifies it with an `?item=...` query parameter instead, which is not
subject to path normalization. Honor that parameter in `api_list()`
whenever the routed path leaves `api->item` empty. `POST` intentionally
carries the item in the payload and is left untouched; the change covers
`GET`/`PUT`/`DELETE` for every list type.

The value is decoded into a buffer sized from the raw query string. As
the URL-decoded result is never longer than its encoding, this can never
truncate, even for long regular expressions or adlist URLs. The dispatch
now has a single exit so the buffer is freed exactly once.

See pi-hole/web#3308

Signed-off-by: DL6ER <dl6er@dl6er.de>
@DL6ER
DL6ER force-pushed the fix/list-item-query-fallback branch from 2d57d57 to 243e6fd Compare July 12, 2026 13:28
@yubiuser

Copy link
Copy Markdown
Member

This adds a backward-compatible fallback in api_list(): when the routed path leaves api->item empty, we consult an ?item=... query parameter, which is not subject to path dot-segment normalization.

With this changes here and in pi-hole/web#3818 the?item= query parameter will be always used by us. Maybe we should change the default to prefer the query parameter over the path parameter?

The `?item=` fallback was only consulted when the routed path left the
item empty, so a request carrying both simply ignored the query value.
That is safe but silent: nothing told the caller that half of its request
was dropped.

Parse the query parameter whenever it is present and treat a mismatch as
a client error (400). The URI path remains the resource identifier - we
never let a query value overrule it, as acting on something else than
what a reverse proxy or access log in front of us has seen is worse than
refusing the request. Both forms may be given at once only if they name
the same item, which keeps every existing client working unchanged.

Adds pytest coverage for the three cases: `?item=` alone (a group named
`.`), path and query in agreement, and the rejected conflict.

Signed-off-by: DL6ER <dl6er@dl6er.de>
@DL6ER

DL6ER commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

The web never sends both, so it cannot see the precedence either way - third-party clients can. If the query won, PUT /domains/deny/exact/a.com?item=b.com would silently edit b.com while every proxy and access log in front of us recorded a.com. The path is the resource identifier, so I would rather not let a query value overrule it.

Instead of picking a winner, I made the ambiguity an error: we now parse ?item= whenever it is present and return 400 if it disagrees with the path. Both may still be given as long as they name the same item, so nothing existing breaks.

The form the web uses, PUT /api/groups?item=..., is unaffected: when the path carries no item there is nothing to disagree with, so no comparison against an empty in-path parameter is attempted and the query value is simply used.

Added in 5e5338c together with pytest coverage for all three cases.

`get_string_var()` ran `mg_url_decode()` over the value `mg_get_var()`
returns, but `mg_get_var()` has already decoded it: we leave CivetWeb's
`decode_query_string` at its default `no`, so the query string arrives raw
and CivetWeb decodes it exactly once when extracting a variable.

The second pass changed the value again. `?item=a%2541b` became `aAb`
instead of `a%41b`, and any value containing a space or a `+` made
`mg_url_decode()` return `-1`, so the parameter silently read as absent.
This was harmless enough while `?item=` did not exist, but it is not any
more: we now compare the (once-decoded) path item with the (twice-decoded)
query item and answer 400 when they disagree, so a client naming a group
with a `%` in it got its request rejected.

Return what `mg_get_var()` gives us. Variable *names* are still matched
against the raw query string, so the percent-encoded names in `queries.c`
(e.g. `order%5B0%5D%5Bdir%5D`) keep working.

Adds pytest coverage for both cases: a group whose name contains `%`
addressed through path and `?item=` at once, and one containing a space
addressed through `?item=` alone.

Signed-off-by: DL6ER <dl6er@dl6er.de>
@rdwebdesign
rdwebdesign requested a review from yubiuser August 8, 2026 22:35
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

Conflicts have been resolved.

The fallback added in this branch is a public API surface - `pi-hole/web#3818` uses `PUT /api/groups?item=...` as its regular edit path - but nothing described it.

1. `common.yaml` gains a shared `item` query parameter explaining why it exists (browsers collapse a `.`/`..` path segment) and that it is only consulted when the URI path carries no item, plus an `item_required` variant for the routes where nothing else identifies the resource.
2. `groups`, `clients`, `lists` and `domains` reference it on the operations that take an item, and gain `PUT`/`DELETE` on the collection URI (`/api/groups`, `/api/domains/{type}/{kind}`, ...) - the form the web actually sends. The new operations reuse the existing ones through a YAML anchor, so only the description, the parameters and the `operationId` differ.
3. Each of the four specs gains an `item_mismatch` example for the `400` returned when the URI path and the query name different items.

`api_endpoints()` builds FTL's endpoint list from the static table in `api.c`, and `test_all_endpoints_cross_check` compares that table against the specs in both directions, so the argument-less rows have to list the two methods as well. That is declarative only: the dispatch loop stops at the first row whose URI matches, which is always the more specific row above.

Signed-off-by: DL6ER <dl6er@dl6er.de>
@DL6ER
DL6ER force-pushed the fix/list-item-query-fallback branch from 3fb44c0 to ec5017f Compare August 9, 2026 08:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants