feat: basic functionality - #1
Conversation
3320508 to
4d3cbdd
Compare
skomski
left a comment
There was a problem hiding this comment.
Reviewed the WebSocket↔DNS translation. The in-place approach is sound: both sides are message-oriented, and the write cursor always trails the read cursor — pong output lands at least 4 bytes behind its source, and the reserved prefix area in dns_to_ws works out exactly (512 messages × 2 bytes = the 1024-byte offset; tight, but correct). Everything below is framing arithmetic and buffer sizing around that approach, not the approach itself.
Two are confirmed against the built binary rather than inferred:
- DNS messages larger than 65531 bytes are silently dropped (
src/main.rsline 189) - A query pipelined ahead of a Close frame never reaches upstream (
src/translate.rsline 143)
Two more are latent and share a root cause with the first: "buffer full" and "peer closed" are currently the same signal (src/main.rs line 197), so a frame that cannot fit turns into a clean-looking close instead of an error.
I have both bugs reproduced as integration tests and all four fixes working locally — all 34 tests green, clippy and fmt clean. Happy to push that if useful.
Smaller things, not worth their own threads:
- The proxy always answers with code 1000 regardless of the client's close code; RFC 6455 §5.5.1 suggests echoing it.
- No idle or read timeout anywhere. Each WebSocket connection pins an upstream TCP socket for its lifetime, and there is no cap on concurrent connections. RFC 7766 §6.2.3 expects timeouts on DNS-over-TCP. This gets more relevant if the half-close drain described on
src/translate.rsline 143 lands, since that drain waits on upstream EOF. - A zero-length binary message is forwarded upstream as a 0-length DNS message rather than rejected.
- Non-minimal length encodings are accepted (a 5-byte payload sent with the 126 extended form, say). Harmless here, just noting the leniency.
| let mut dns_pos = 0; | ||
|
|
||
| let code = loop { | ||
| let size @ 1.. = sock_r.read(&mut ws_buf[ws_fill..]).await? else { |
There was a problem hiding this comment.
A full buffer makes read return Ok(0), which is indistinguishable from EOF
When ws_fill == BUF_SIZE, &mut ws_buf[ws_fill..] is a zero-length slice, so read returns Ok(0) without touching the socket. The 1.. pattern fails and the loop breaks with 1000 — the same branch that handles a real EOF.
Progress depends entirely on ws_to_dns consuming bytes. When it returns ws_pos == 0 because a frame is incomplete, lines 216-217 shift nothing and ws_fill only grows. A frame that can never fit therefore becomes a silent close rather than an error. That is the mechanism behind both the >65531-byte drop (line 189) and the unreachable 1009 (src/translate.rs line 164).
Worth separating the two conditions even after the buffer is resized, so a future sizing mistake surfaces as a protocol error instead of a silent drop:
| let size @ 1.. = sock_r.read(&mut ws_buf[ws_fill..]).await? else { | |
| // A full buffer would make `read` return `Ok(0)` without touching the | |
| // socket, which is indistinguishable from EOF. `ws_buf` holds the | |
| // largest legal frame, so this only triggers on input that can never | |
| // be framed. | |
| let Some(space @ [_, ..]) = ws_buf.get_mut(ws_fill..) else { | |
| break 1009; | |
| }; | |
| let size @ 1.. = sock_r.read(space).await? else { |
forward_responses on line 157 has the same shape but cannot reach it: the largest unprocessed trailer is 65536 bytes copied back to offset 1024, so dns_fill peaks at 66560 against a BUF_SIZE of 66561. One byte of slack — correct, but with no margin.
There was a problem hiding this comment.
This condition (0 bytes free buffer space) should never happen. If it unexpectedly happens due to a bug, treating it as EOF is not catastrophic (as looping forever would be, for example). Therefore, I don't see any reasons to improve this.
| let old_ws_pos = ws_pos; | ||
| ws_pos = data_range.end; | ||
| if minimal_header[0] == MASKED | CLOSE { | ||
| return Err(1000); |
There was a problem hiding this comment.
Err(1000) discards queries already translated in the same call
Err throws away the dns_data_complete and pong_pos accumulated earlier in this call, and forward_requests breaks before reaching its up_w.write_all. A client that pipelines a query and a Close into one TCP segment gets no answer — the query never reaches upstream.
Verified end-to-end: a 12-byte query frame followed by a Close frame in a single write_all comes back as only a Close frame, no DNS response.
Nothing makes that client behaviour illegal. It has stopped sending, but the proxy may keep sending until it emits its own Close, so the answer is still deliverable.
Two pieces are needed:
1. Report the close code alongside the state instead of through Err, so the caller can flush what was already translated:
close_code = Some(1000);
break;with the signature becoming Result<(usize, usize, usize, usize, Option<u16>), u16> and the tail returning Ok((dns_data_complete, dns_data_pos, pong_pos, ws_pos, close_code)).
2. That alone is not sufficient — I tried it, and the query does reach upstream but the answer still never arrives. forward_requests takes sock_w, writes Close, and returns Err, which makes the try_join! in handle_connection drop forward_responses before the response comes back. It needs half-close semantics instead:
if close_code.is_some() {
// The client is done sending, but queries forwarded just above may
// still be unanswered. Half-close upstream so it sees the end of the
// query stream, and leave the closing handshake to `forward_responses`.
up_w.shutdown().await?;
return Ok(());
}With both pieces plus the src/main.rs line 189 fix, a regression test for this passes.
One caveat: the drain then waits on upstream EOF, and there are no timeouts anywhere in the proxy. Not a regression — an idle client can already hold a connection open indefinitely — but it does make the missing timeouts more visible.
There was a problem hiding this comment.
"Nothing makes that client behaviour illegal."
Yes, but there is also nothing that prevents the server from cancelling outstanding requests when the client closes the connection. The client should simply keep the connection open until it receives all responses, if the client is still interested in them.
One caveat
This "one caveat" is also against RFC 6455 (The WebSocket Protocol), which says that about sending the Close response: "It SHOULD do so as soon as practical." This whole problem can be solved by cancelling outstanding requests on close (as the code currently does). By the way, timeouts can be configured in the reverse proxy if necessary.
c23e926 to
101fa11
Compare
| let path = request.path.unwrap(); | ||
| let (path, _query) = path.split_once("?").unwrap_or((path, "")); | ||
| let upstream_addr = path.rsplit_once("/").map(|(_, end)| end).unwrap_or(path); | ||
| let mut upstream = match TcpStream::connect(parse_upstream_addr(upstream_addr)).await { |
There was a problem hiding this comment.
So I understand this won't be publicly available and the Readme says if so, then a reverse proxy would be forwarding specific paths only. Still, this seems like a weak spot to me. Maybe an allowlist could be part of this directly.
There was a problem hiding this comment.
Ok, I've added an allowlist (DOWS_ALLOWED_UPSTREAMS).
| Ok(upstream) => upstream, | ||
| Err(e) => { | ||
| let mut buf = [0u8; _]; | ||
| sock.write_all(close_frame(&mut buf, 1000, e)).await?; |
There was a problem hiding this comment.
Also for safety, might be better to return a generic error reason to the client and keep the detail server-side.
There was a problem hiding this comment.
The detailed error messages (name not resolved, connection refused, timeout) have already proven useful to me. I could imagine that it would be even more helpful for others who have not set up all these services. As Fable 5 phrased it: "The behavior is fine as-is if debuggability for clients is a goal, which for a DNS proxy it probably is."
Fable 5 suggests that an upstream allowlist is sufficient for security purposes, and the error message can be passed as is.
Detailed security analysis of Fable 5 (I asked "is it safe to send the error message to the client?", not "why is it safe?" or similar)
Content of the error — benign in practice.** The
ehere comes fromTcpStream::connect(parse_upstream_addr(upstream_addr)). Typicalio::ErrorDisplay values are things like:
Connection refused (os error 111)failed to lookup address information: Name or service not knownNetwork is unreachable (os error 101)These don't contain server file paths, secrets, or (on Linux/glibc) even the hostname being resolved — so there's no reflected attacker-controlled data and no server-internal detail beyond the error kind itself.
The caveat: it's a network-probing oracle. The client picks the upstream address from the URL path, and the server connects to it. Distinguishing "connection refused" vs "timed out" vs "network unreachable" vs "name not known" lets a client map out the network the proxy sits on (classic SSRF-adjacent port scanning). However — and this is the honest counterpoint — that oracle largely exists anyway: connect success/failure is already visible, and refused-vs-timeout is inferable from timing. The error text only makes it slightly more convenient. If this proxy is intentionally an open "connect to any DNS server" service, that's an accepted design property; if it's deployed inside a network with things worth hiding, you'd want an upstream allowlist, not just a vaguer error message.
| for h in req.headers.iter() { | ||
| if h.name.eq_ignore_ascii_case("upgrade") { | ||
| upgrade |= contains_val(h.value, b"websocket"); | ||
| } else if h.name.eq_ignore_ascii_case("connection") { | ||
| connection |= contains_val(h.value, b"upgrade"); | ||
| } else if h.name.eq_ignore_ascii_case("sec-websocket-version") { | ||
| version = h.value.trim_ascii().eq_ignore_ascii_case(b"13"); | ||
| } else if h.name.eq_ignore_ascii_case("sec-websocket-key") { | ||
| key = std::str::from_utf8(h.value).ok(); | ||
| } | ||
| } | ||
|
|
||
| key.filter(|_| upgrade && connection && version) |
There was a problem hiding this comment.
I don't really see any issue when other origins also connect to the proxy or the allowed upstream. If all upstreams were allowed, maybe it would make sense to restrict the origin instead, but malicious clients could also send a wrong origin, so this would only help against in-browser malicious clients. Also, there is no inherently right origin here, so are you suggesting to add another allowlist? Is there a specific attack scenario you want to prevent?
There was a problem hiding this comment.
Yes, concrete scenario:
dows is reachable from a browser by design (admin2136 talks to it via js/dows.js). So take a deployment where dows is reachable from employee browsers and the allowlist permits an internal resolver. An employee visits an unrelated site. That page opens a WebSocket to dows. WebSockets aren't subject to CORS, so it connects and the page can read the responses. It now has a query channel into the internal resolver, from inside our network, with dows's source IP — name enumeration, and AXFR if the resolver authorizes by source address.
The upstream allowlist restricts which server we proxy to. It doesn't restrict who may use the proxy. Those are separate controls and only the second one stops the above.
You're right that Origin does nothing against a non-browser client — that one needs auth or network policy, which I'd treat as a separate discussion. But Origin is precisely the control for the confused-deputy case, and it's reliable there: browsers set it and page JS can't override it.
So yes, I'm suggesting a second, optional allowlist — DOWS_ALLOWED_ORIGINS, unset = don't check. Then admin2136's origin goes on the list and random pages don't. I understand if you see it as an overkill, I will leave it for your consideration. Maybe at least a README note stating that dows must not be reachable from browsers that also visit untrusted pages.
| repository = "https://github.com/famedly/dows" | ||
|
|
||
| [dependencies] | ||
| base64-turbo = "0.2.0" |
There was a problem hiding this comment.
Yes, it is a niche crate. I replaced it with base64ct which is already a transitive dependency in a lot or even most of our projects.
PR SummaryMedium Risk Overview Runtime behavior: concurrent connections on Tokio; optional allowlists via required Repo and delivery: Nix flake (static binary, layered Docker image), generated GitHub Actions for build (multi-arch artifacts, registry push, tag releases), tests ( Reviewed by Cursor Bugbot for commit 83be85c. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Stalled request after fragment flush
- Fixed forward_requests to re-translate buffered WS data after DNS flush.
Or push these changes by commenting:
@cursor push 6c2bb0cb87
Preview (6c2bb0cb87)
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
--- /dev/null
+++ b/.gitattributes
@@ -1,0 +1,7 @@
+.pre-commit-config.yaml linguist-generated
+.github/workflows/build.yml linguist-generated
+.github/workflows/check-pre-commit-hooks.yml linguist-generated
+.github/workflows/tests.yml linguist-generated
+.gitattributes linguist-generated
+treefmt.toml linguist-generated
+rustfmt.toml linguist-generated
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -1,0 +1,128 @@
+# This file is automatically generated from Nix configuration. Do not edit directly.
+
+concurrency:
+ cancel-in-progress: true
+ group: ${{ github.workflow }}-${{ github.ref }}
+jobs:
+ build:
+ runs-on: ${{ matrix.runner }}
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
+ - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3
+ - name: Build static binary
+ run: |
+ nix build .#dows --print-build-logs -o result-dows
+ - name: Build Docker image
+ run: nix build .#docker-image --print-build-logs
+ - name: Upload static binary as artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ if-no-files-found: error
+ name: dows-linux-${{ matrix.arch }}
+ path: result-dows/bin/dows
+ - name: Upload Docker image as artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ if-no-files-found: error
+ name: docker-image-${{ matrix.arch }}
+ path: result
+ strategy:
+ matrix:
+ include:
+ - arch: x86_64
+ runner: ubuntu-26.04
+ - arch: aarch64
+ runner: ubuntu-26.04-arm
+ docker:
+ if: github.event_name == 'push' || github.event_name == 'pull_request'
+ needs:
+ - build
+ runs-on: ubuntu-26.04-arm
+ steps:
+ - name: Download Docker images
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
+ with:
+ path: artifacts
+ pattern: docker-image-*
+ - env:
+ REGISTRY_PASSWORD: ${{ secrets.registry_password || secrets.GITHUB_TOKEN }}
+ REGISTRY_USER: ${{ vars.REGISTRY_USER }}
+ TAG: ${{ github.head_ref || github.ref_name || 'latest' }}
+ name: Push multi-arch Docker manifest to registry
+ run: |
+ if [[ "$GITHUB_REF_NAME" =~ v[0-9]+\.[0-9]+\.[0-9]+ ]]; then
+ registry=registry.famedly.net/docker-oss
+ else
+ registry=registry.famedly.net/docker-nightly
+ fi
+
+ echo "$REGISTRY_PASSWORD" \
+ | podman login registry.famedly.net -u "$REGISTRY_USER" --password-stdin
+
+ image="$registry/dows"
+ # Branch names may contain slashes, which are not valid in
+ # Docker tags.
+ tag="${TAG//\//-}"
+
+ # Combine the per-arch images into a multi-arch manifest.
+ # Every `podman load` overwrites `dows:latest`, so retag
+ # each image with an arch suffix before loading the next.
+ podman manifest create dows-multiarch
+ for arch in x86_64 aarch64; do
+ podman load < "artifacts/docker-image-$arch/result"
+ podman tag dows:latest "dows:$arch"
+ podman manifest add dows-multiarch "containers-storage:localhost/dows:$arch"
+ done
+
+ # `--all` pushes the per-arch images along with the
+ # manifest (by digest only, so no arch-specific tags show
+ # up in the registry). Publish under both the branch/tag
+ # name and the commit SHA.
+ podman manifest push --all dows-multiarch "docker://$image:$tag"
+ podman manifest push --all dows-multiarch "docker://$image:$GITHUB_SHA"
+ release:
+ if: startsWith(github.ref, 'refs/tags/')
+ needs:
+ - build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download static binaries
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
+ with:
+ path: artifacts
+ pattern: dows-linux-*
+ - env:
+ GH_REPO: ${{ github.repository }}
+ GH_TOKEN: ${{ github.token }}
+ name: Create GitHub release
+ run: |
+ tag="${GITHUB_REF#refs/tags/}"
+
+ # The file inside each artifact is just called `dows`;
+ # rename it to the artifact's arch-suffixed name for the
+ # release assets.
+ for dir in artifacts/dows-linux-*; do
+ install -m755 "$dir/dows" "$(basename "$dir")"
+ done
+
+ gh release create "$tag" \
+ --verify-tag \
+ dows-linux-*
+name: Build
+"on":
+ merge_group: {}
+ pull_request:
+ branches:
+ - '**'
+ types:
+ - opened
+ - reopened
+ - synchronize
+ - ready_for_review
+ push:
+ branches:
+ - main
+ tags:
+ - v*
+permissions:
+ contents: write
diff --git a/.github/workflows/check-pre-commit-hooks.yml b/.github/workflows/check-pre-commit-hooks.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/check-pre-commit-hooks.yml
@@ -1,0 +1,27 @@
+# This file is automatically generated from Nix configuration. Do not edit directly.
+
+concurrency:
+ cancel-in-progress: true
+ group: ${{ github.workflow }}-${{ github.ref }}
+jobs:
+ prek:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
+ - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3
+ - env:
+ TREEFMT_NO_CACHE: "1"
+ name: Run pre-commit hooks
+ run: prek --all-files --show-diff-on-failure
+ shell: nix develop .#standards --command bash {0}
+name: Make sure all pre-commit hooks pass
+"on":
+ merge_group: {}
+ pull_request:
+ branches:
+ - '**'
+ types:
+ - opened
+ - reopened
+ - synchronize
+ - ready_for_review
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -1,0 +1,25 @@
+# This file is automatically generated from Nix configuration. Do not edit directly.
+
+concurrency:
+ cancel-in-progress: true
+ group: ${{ github.workflow }}-${{ github.ref }}
+jobs:
+ nextest:
+ runs-on: ubuntu-26.04-arm
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
+ - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3
+ - name: Run tests
+ run: cargo nextest run --all-targets --all-features
+ shell: nix develop .#rust --command bash {0}
+name: Run tests
+"on":
+ merge_group: {}
+ pull_request:
+ branches:
+ - '**'
+ types:
+ - opened
+ - reopened
+ - synchronize
+ - ready_for_review
diff --git a/.gitignore b/.gitignore
new file mode 100644
--- /dev/null
+++ b/.gitignore
@@ -1,0 +1,5 @@
+/target/
+result
+result-*
+.devenv/
+.direnv/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -1,0 +1,66 @@
+repos:
+- hooks:
+ - id: check-added-large-files
+ - id: check-case-conflict
+ - id: check-illegal-windows-names
+ - id: end-of-file-fixer
+ - id: fix-byte-order-marker
+ - id: check-json
+ - id: check-json5
+ - id: check-toml
+ - id: check-vcs-permalinks
+ - id: check-xml
+ - args:
+ - --fix=lf
+ id: mixed-line-ending
+ - id: check-symlinks
+ - id: destroyed-symlinks
+ - id: check-merge-conflict
+ - id: detect-private-key
+ - id: check-shebang-scripts-are-executable
+ - id: check-executables-have-shebangs
+ repo: builtin
+- hooks:
+ - args:
+ - --write-changes
+ - --force-exclude
+ description: Check the repository for spelling mistakes
+ entry: typos
+ id: typos
+ language: system
+ name: typos
+ types:
+ - text
+ - description: Ensure that files set up with the filegen module are up-to-date
+ entry: filegen-apply-script
+ id: filegen
+ language: system
+ name: filegen
+ pass_filenames: false
+ - description: Format *all* files
+ entry: treefmt
+ id: treefmt
+ language: system
+ name: treefmt
+ require_serial: true
+ repo: local
+- hooks:
+ - args:
+ - follow
+ description: Ensure that flake inputs are recursively de-duplicated
+ entry: flake-edit
+ files:
+ glob: '{flake.nix,flake.lock}'
+ id: flake-follows
+ language: system
+ name: flake-follows
+ pass_filenames: false
+ repo: local
+- hooks:
+ - description: Check licensing info for REUSE compliance
+ entry: reuse lint
+ id: reuse
+ language: system
+ name: reuse
+ pass_filenames: false
+ repo: local
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
--- /dev/null
+++ b/Cargo.lock
@@ -1,0 +1,576 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer 0.10.4",
+ "crypto-common 0.1.7",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.1",
+ "const-oid",
+ "crypto-common 0.2.2",
+]
+
+[[package]]
+name = "dows"
+version = "0.1.0"
+dependencies = [
+ "base64ct",
+ "futures-util",
+ "httparse",
+ "nix",
+ "percent-encoding",
+ "sha1 0.11.0",
+ "tokio",
+ "tokio-tungstenite",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
+
+[[package]]
+name = "futures-task"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+
+[[package]]
+name = "futures-util"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hybrid-array"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "nix"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha1"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tokio-tungstenite"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
+[[package]]
+name = "tungstenite"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand",
+ "sha1 0.10.7",
+ "thiserror",
+ "utf-8",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
... diff truncated: showing 800 of 3410 linesYou can send follow-ups to the cloud agent here.
f2f6589 to
c00a924
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
Autofix Details
Done
Or push these changes by commenting:
@cursor push 33643d232c
Preview (33643d232c)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -23,4 +23,5 @@
If you expose this service publicly, you can restrict the allowed upstreams by
setting the environment variable `DOWS_ALLOWED_UPSTREAMS` to a comma-separated
-list of allowed upstreams.
+list of allowed upstreams. Entries are normalized like path segments (URI
+authority, percent-decoded, default port 53) before matching.
diff --git a/nix/package.nix b/nix/package.nix
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -25,7 +25,7 @@
cargoLock.lockFile = ../Cargo.lock;
- env.GIT_COMMIT_HASH = inputs.self.rev;
+ env.GIT_COMMIT_HASH = inputs.self.rev or inputs.self.dirtyRev or "unknown";
meta = {
description = "DNS over WebSocket proxy";
diff --git a/src/main.rs b/src/main.rs
--- a/src/main.rs
+++ b/src/main.rs
@@ -123,14 +123,17 @@
let path = request.path.unwrap();
let (path, _query) = path.split_once("?").unwrap_or((path, ""));
- let upstream_addr = path.rsplit_once("/").map(|(_, end)| end).unwrap_or(path);
- if allowed_upstreams.is_some_and(|x| !x.split(",").any(|x| x == upstream_addr)) {
+ let upstream_addr =
+ parse_upstream_addr(path.rsplit_once("/").map(|(_, end)| end).unwrap_or(path));
+ if allowed_upstreams
+ .is_some_and(|x| !x.split(",").any(|x| parse_upstream_addr(x) == upstream_addr))
+ {
let mut buf = [0u8; _];
const MESSAGE: &str = "upstream prohibited by proxy configuration";
sock.write_all(close_frame(&mut buf, 1000, MESSAGE)).await?;
return Ok(());
}
- let mut upstream = match TcpStream::connect(parse_upstream_addr(upstream_addr)).await {
+ let mut upstream = match TcpStream::connect(&upstream_addr).await {
Ok(upstream) => upstream,
Err(e) => {
let mut buf = [0u8; _];You can send follow-ups to the cloud agent here.
759287f to
438428b
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Allowlist ignores spaced entries
- Trimmed and skipped empty segments when splitting DOWS_ALLOWED_UPSTREAMS so spaced entries match after parse_upstream_addr.
Or push these changes by commenting:
@cursor push 33701377dd
Preview (33701377dd)
diff --git a/src/main.rs b/src/main.rs
--- a/src/main.rs
+++ b/src/main.rs
@@ -125,9 +125,12 @@
let (path, _query) = path.split_once("?").unwrap_or((path, ""));
let final_segment = path.rsplit_once("/").map(|(_, end)| end).unwrap_or(path);
let upstream_addr = parse_upstream_addr(&percent_decode_str(final_segment).decode_utf8_lossy());
- if allowed_upstreams
- .is_some_and(|x| !x.split(",").any(|x| parse_upstream_addr(x) == upstream_addr))
- {
+ if allowed_upstreams.is_some_and(|x| {
+ !x.split(",")
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .any(|x| parse_upstream_addr(x) == upstream_addr)
+ }) {
let mut buf = [0u8; _];
const MESSAGE: &str = "upstream prohibited by proxy configuration";
sock.write_all(close_frame(&mut buf, 1000, MESSAGE)).await?;You can send follow-ups to the cloud agent here.
2fa8de1 to
1b07168
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: Test harness can hang forever
- The startup loop now panics when read_line returns 0 (EOF), so a proxy that exits before printing its listen address fails the test instead of hanging.
- ✅ Fixed: Tests inherit upstream allowlist
- The spawned proxy command now calls env_remove("DOWS_ALLOWED_UPSTREAMS") so an ambient allowlist cannot reject the test DNS port.
- ✅ Fixed: Upstream write skips close frame
- A failed upstream write now breaks into the existing close-frame path with the error reason instead of returning via ? and dropping the client without a handshake.
Or push these changes by commenting:
@cursor push e68ce5e76f
Preview (e68ce5e76f)
diff --git a/src/main.rs b/src/main.rs
--- a/src/main.rs
+++ b/src/main.rs
@@ -214,20 +214,22 @@
// the output buffer without reading new data.
let mut read = true;
- let code = loop {
+ let close_msg = loop {
if read {
let size @ 1.. = sock_r.read(&mut ws_buf[ws_fill..]).await? else {
- break 1000;
+ break close_frame(dns_buf.first_chunk_mut().unwrap(), 1000, "");
};
ws_fill += size;
}
let (dns_complete, dns_end, pong, ws_pos) =
match ws_to_dns(&mut ws_buf[..ws_fill], &mut dns_buf, dns_pos) {
Ok(state) => state,
- Err(code) => break code,
+ Err(code) => break close_frame(dns_buf.first_chunk_mut().unwrap(), code, ""),
};
- if dns_complete > 0 {
- up_w.write_all(&dns_buf[..dns_complete]).await?;
+ if dns_complete > 0
+ && let Err(e) = up_w.write_all(&dns_buf[..dns_complete]).await
+ {
+ break close_frame(dns_buf.first_chunk_mut().unwrap(), 1000, e);
}
if pong > 0
&& let Some(sock_w) = sock_w.lock().await.as_mut()
@@ -242,7 +244,6 @@
};
if let Some(mut sock_w) = sock_w.lock().await.take() {
- let close_msg = close_frame(dns_buf.first_chunk_mut().unwrap(), code, "");
sock_w.write_all(close_msg).await?;
}
diff --git a/tests/proxy.rs b/tests/proxy.rs
--- a/tests/proxy.rs
+++ b/tests/proxy.rs
@@ -82,6 +82,7 @@
// its actual listening address on the first line of stdout.
let mut child = Command::new(env!("CARGO_BIN_EXE_dows"))
.arg("[::1]:0")
+ .env_remove("DOWS_ALLOWED_UPSTREAMS")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.spawn()
@@ -91,7 +92,9 @@
let mut line = String::new();
let endpoint = loop {
line.clear();
- stdout.read_line(&mut line).expect("read proxy address");
+ if stdout.read_line(&mut line).expect("read proxy address") == 0 {
+ panic!("proxy exited before printing listening address");
+ }
if let Some(endpoint) = line.strip_prefix("listening on ") {
break endpoint.trim().to_owned();
}You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Header buffer limit too low
- Raised the httparse header array from 32 to 64 and now returns HTTP 431 when TooManyHeaders is still exceeded instead of closing the TCP connection.
Or push these changes by commenting:
@cursor push 16914ea5c7
Preview (16914ea5c7)
diff --git a/src/main.rs b/src/main.rs
--- a/src/main.rs
+++ b/src/main.rs
@@ -79,9 +79,16 @@
return Err(io::ErrorKind::UnexpectedEof.into());
};
data_len += size;
- headers = [httparse::EMPTY_HEADER; 32];
+ headers = [httparse::EMPTY_HEADER; 64];
request = httparse::Request::new(&mut headers);
match request.parse(&buf[..data_len]) {
+ Err(httparse::Error::TooManyHeaders) => {
+ let resp = "HTTP/1.1 431 Request Header Fields Too Large\r\n\
+ Connection: close\r\n\
+ Content-Type: text/plain\r\n\r\n";
+ sock.write_all(resp.as_bytes()).await?;
+ return Ok(());
+ }
Err(e) => return Err(io::Error::new(io::ErrorKind::InvalidData, e)),
Ok(Status::Complete(len)) => break len,
_ => {}You can send follow-ups to the cloud agent here.
jannden
left a comment
There was a problem hiding this comment.
I don't see a reason to block this further, but have one new finding.
| let allowed_upstreams = std::env::var("DOWS_ALLOWED_UPSTREAMS").ok(); | ||
| let listen_arg = std::env::args().nth(1); | ||
| let listen_str = listen_arg.as_deref().unwrap_or("[::]:8080"); | ||
| let listener = TcpListener::bind(listen_str).await?; | ||
| println!("listening on {}", listener.local_addr()?); | ||
| let mut sigterm = signal(SignalKind::terminate())?; | ||
| let mut sigint = signal(SignalKind::interrupt())?; | ||
| loop { | ||
| tokio::select! { | ||
| accepted = listener.accept() => { | ||
| let (sock, _addr) = accepted?; | ||
| tokio::spawn(handle_connection(sock, allowed_upstreams.clone())); | ||
| } |
There was a problem hiding this comment.
This proxy lets the client choose which DNS server to talk to, by putting it in the URL. So a client connects to ws://your-proxy:8080/8.8.8.8 and the proxy dials 8.8.8.8:53 for them. That part is intentional and documented — fine.
The problem is what happens when nobody configures the restriction:
The proxy listens on all network interfaces by default ([::]:8080).
The allowlist (DOWS_ALLOWED_UPSTREAMS) is optional. Unset means "any upstream is fine."
So if you just run the Docker image, anyone who can reach port 8080 can point it at anything the proxy's machine can reach — your internal DNS servers, 127.0.0.1, other ports, cloud metadata endpoints. They're borrowing the proxy's network position. That's the classic "open proxy / SSRF" shape.
So maybe it should be changed to: if the allowlist isn't configured, don't start. Make the operator say explicitly "yes, I want this open" (e.g. by setting it to *). Right now the safe setup is the one you have to remember to do, and the dangerous one is the default.
| for h in req.headers.iter() { | ||
| if h.name.eq_ignore_ascii_case("upgrade") { | ||
| upgrade |= contains_val(h.value, b"websocket"); | ||
| } else if h.name.eq_ignore_ascii_case("connection") { | ||
| connection |= contains_val(h.value, b"upgrade"); | ||
| } else if h.name.eq_ignore_ascii_case("sec-websocket-version") { | ||
| version = h.value.trim_ascii().eq_ignore_ascii_case(b"13"); | ||
| } else if h.name.eq_ignore_ascii_case("sec-websocket-key") { | ||
| key = std::str::from_utf8(h.value).ok(); | ||
| } | ||
| } | ||
|
|
||
| key.filter(|_| upgrade && connection && version) |
There was a problem hiding this comment.
Yes, concrete scenario:
dows is reachable from a browser by design (admin2136 talks to it via js/dows.js). So take a deployment where dows is reachable from employee browsers and the allowlist permits an internal resolver. An employee visits an unrelated site. That page opens a WebSocket to dows. WebSockets aren't subject to CORS, so it connects and the page can read the responses. It now has a query channel into the internal resolver, from inside our network, with dows's source IP — name enumeration, and AXFR if the resolver authorizes by source address.
The upstream allowlist restricts which server we proxy to. It doesn't restrict who may use the proxy. Those are separate controls and only the second one stops the above.
You're right that Origin does nothing against a non-browser client — that one needs auth or network policy, which I'd treat as a separate discussion. But Origin is precisely the control for the confused-deputy case, and it's reliable there: browsers set it and page JS can't override it.
So yes, I'm suggesting a second, optional allowlist — DOWS_ALLOWED_ORIGINS, unset = don't check. Then admin2136's origin goes on the list and random pages don't. I understand if you see it as an overkill, I will leave it for your consideration. Maybe at least a README note stating that dows must not be reachable from browsers that also visit untrusted pages.
b9ebb9d to
8072f04
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Origin allowlist ignores spaces
- DOWS_ALLOWED_ORIGINS now trims each comma-separated entry and the Origin header value before matching, so lists like "https://a.example, https://b.example" work the same as the upstream allowlist.
Or push these changes by commenting:
@cursor push fffe951a5a
Preview (fffe951a5a)
diff --git a/src/main.rs b/src/main.rs
--- a/src/main.rs
+++ b/src/main.rs
@@ -98,11 +98,9 @@
for h in request.headers.iter() {
if h.name.eq_ignore_ascii_case("origin") {
- if policy
- .allowed_origins
- .as_ref()
- .is_some_and(|x| !x.split(",").any(|x| x.as_bytes() == h.value))
- {
+ if policy.allowed_origins.as_ref().is_some_and(|x| {
+ !x.split(",").map(str::trim).any(|x| x.as_bytes() == h.value.trim_ascii())
+ }) {
let resp = "HTTP/1.1 403 Forbidden\r\n\
Connection: close\r\n\
Content-Type: text/plain\r\n\r\n";You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 83be85c. Configure here.
| if policy | ||
| .allowed_origins | ||
| .as_ref() | ||
| .is_some_and(|x| !x.split(",").any(|x| x.as_bytes() == h.value)) |
There was a problem hiding this comment.
Origin allowlist ignores spaces
Medium Severity
Issue: DOWS_ALLOWED_ORIGINS splits on commas but does not trim entries, unlike DOWS_ALLOWED_UPSTREAMS. A normal list like https://a.example, https://b.example fails to match the second origin, so legitimate browser clients get 403.
Fix: Trim each origin entry (and ideally the header value) before comparing, matching the upstream allowlist path.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 83be85c. Configure here.



No description provided.