Skip to content

feat: basic functionality - #1

Open
lukaslihotzki-f wants to merge 1 commit into
mainfrom
dev
Open

feat: basic functionality#1
lukaslihotzki-f wants to merge 1 commit into
mainfrom
dev

Conversation

@lukaslihotzki-f

Copy link
Copy Markdown
Collaborator

No description provided.

@lukaslihotzki-f
lukaslihotzki-f force-pushed the dev branch 6 times, most recently from 3320508 to 4d3cbdd Compare July 28, 2026 10:48

@skomski skomski left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.rs line 189)
  • A query pipelined ahead of a Close frame never reaches upstream (src/translate.rs line 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.rs line 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.

Comment thread src/main.rs Outdated
Comment thread src/main.rs Outdated
let mut dns_pos = 0;

let code = loop {
let size @ 1.. = sock_r.read(&mut ws_buf[ws_fill..]).await? else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

Suggested change
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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/translate.rs
let old_ws_pos = ws_pos;
ws_pos = data_range.end;
if minimal_header[0] == MASKED | CLOSE {
return Err(1000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

"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.

Comment thread src/translate.rs Outdated
Comment thread src/translate/tests.rs Outdated

@skomski skomski left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM.
Opus found two small issues and for a human there are a lot of magic numbers that are not documented but all should be quick to fix.

@lukaslihotzki-f
lukaslihotzki-f force-pushed the dev branch 12 times, most recently from c23e926 to 101fa11 Compare July 29, 2026 16:35
Comment thread src/main.rs Outdated
Comment on lines +119 to +122
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Ok, I've added an allowlist (DOWS_ALLOWED_UPSTREAMS).

Comment thread src/main.rs
Ok(upstream) => upstream,
Err(e) => {
let mut buf = [0u8; _];
sock.write_all(close_frame(&mut buf, 1000, e)).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Also for safety, might be better to return a generic error reason to the client and keep the detail server-side.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 e here comes from TcpStream::connect(parse_upstream_addr(upstream_addr)). Typical io::Error Display values are things like:

  • Connection refused (os error 111)
  • failed to lookup address information: Name or service not known
  • Network 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.

Comment thread src/main.rs
Comment on lines +250 to +262
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Validating origin would also be good

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread Cargo.toml Outdated
repository = "https://github.com/famedly/dows"

[dependencies]
base64-turbo = "0.2.0"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a weird dependency :)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@cursor

cursor Bot commented Aug 12, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
New network-facing proxy with upstream/origin policy and custom protocol parsing; misconfiguration or framing bugs could affect DNS traffic or allow unintended upstreams if allowlists are wrong.

Overview
Introduces the dows Rust service: a DNS-over-WebSocket proxy that upgrades HTTP to WebSocket, reads the upstream host:port from the last path segment, and bridges WebSocket binary frames to DNS-over-TCP length-prefixed messages via in-place framing helpers in translate.

Runtime behavior: concurrent connections on Tokio; optional allowlists via required DOWS_ALLOWED_UPSTREAMS and DOWS_ALLOWED_ORIGINS (comma-separated lists, or * to disable a check); GET / returns AGPL source info including an embedded git commit from build.rs / Nix GIT_COMMIT_HASH.

Repo and delivery: Nix flake (static binary, layered Docker image), generated GitHub Actions for build (multi-arch artifacts, registry push, tag releases), tests (cargo nextest), and pre-commit; REUSE/AGPL licensing; unit tests for framing and integration tests against a minimal DNS TCP stub and real WebSocket client.

Reviewed by Cursor Bugbot for commit 83be85c. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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 lines

You can send follow-ups to the cloud agent here.

Comment thread src/main.rs
@lukaslihotzki-f
lukaslihotzki-f force-pushed the dev branch 2 times, most recently from f2f6589 to c00a924 Compare August 12, 2026 13:19

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Create PR

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.

Comment thread src/main.rs Outdated
Comment thread nix/package.nix
@lukaslihotzki-f
lukaslihotzki-f force-pushed the dev branch 2 times, most recently from 759287f to 438428b Compare August 12, 2026 14:40

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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.

Comment thread src/main.rs Outdated
@lukaslihotzki-f
lukaslihotzki-f force-pushed the dev branch 2 times, most recently from 2fa8de1 to 1b07168 Compare August 12, 2026 21:05

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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.

Comment thread tests/proxy.rs
Comment thread tests/proxy.rs
Comment thread src/main.rs Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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.

Comment thread src/main.rs

@jannden jannden left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I don't see a reason to block this further, but have one new finding.

Comment thread src/main.rs Outdated
Comment on lines +47 to +59
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()));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/main.rs
Comment on lines +250 to +262
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@lukaslihotzki-f
lukaslihotzki-f force-pushed the dev branch 4 times, most recently from b9ebb9d to 8072f04 Compare August 13, 2026 15:01

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

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.

Create PR

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.

Comment thread src/main.rs
if policy
.allowed_origins
.as_ref()
.is_some_and(|x| !x.split(",").any(|x| x.as_bytes() == h.value))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 83be85c. Configure here.

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.

3 participants