Skip to content

[codex] Fix KiwiSDR proxy redirect transport#3850

Merged
ten9876 merged 2 commits into
aethersdr:mainfrom
rfoust:codex/fix-3790-kiwi-proxy-transport
Jun 27, 2026
Merged

[codex] Fix KiwiSDR proxy redirect transport#3850
ten9876 merged 2 commits into
aethersdr:mainfrom
rfoust:codex/fix-3790-kiwi-proxy-transport

Conversation

@rfoust

@rfoust rfoust commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #3790. KiwiSDR /status preflight now follows validated redirects and uses the final validated status endpoint for WebSocket transport, so proxied receivers such as 21042.proxy.kiwisdr.com:8073 can connect after Kiwi's proxy -> proxy2 split.

Redirect handling is manual and fail-closed: same-host redirects are allowed, and the only cross-host exception is the matching Kiwi proxy receiver alias, such as 21042.proxy.kiwisdr.com -> 21042.proxy2.kiwisdr.com. Redirects to unrelated domains or mismatched proxy aliases are rejected before WebSockets open.

Constitution principle honored

Principle VII — Boundary Input Validation. The redirected /status URL is validated before it can become the WebSocket authority.

Test plan

  • Local build passes: cmake --build build -j10
  • Existing Kiwi tests pass:
    • ctest --test-dir build -R 'kiwi_sdr_protocol_test|kiwi_sdr_trace_math_test|kiwi_public_directory_test' --output-on-failure
  • Reproduction endpoint redirect chain verified:
    • curl -sSIL --max-time 10 http://21042.proxy.kiwisdr.com:8073/status
    • observed 21042.proxy.kiwisdr.com:8073 -> 21042.proxy2.kiwisdr.com -> 21042.proxy2.kiwisdr.com:8073 -> 200 OK
  • Redirected status body verified to report ext_api=3
  • git diff --check

Checklist

  • Commits are signed (docs/COMMIT-SIGNING.md)
  • No new flat-key AppSettings calls — use nested-JSON-under-one-key (Principle V)
  • Code is clean-room — not decompiled, disassembled, or reverse-engineered from a proprietary binary (Principle IV)
  • All meter UI uses MeterSmoother (not applicable; no meter UI touched)
  • Documentation updated if user-visible behavior changed (not applicable; bug fix only)
  • Security-sensitive changes reference a GHSA if applicable (not applicable)

Follow validated KiwiSDR /status redirects and use the final trusted status endpoint for WebSocket transport. Allow same-host redirects and the matching Kiwi proxy-to-proxy2 alias only; reject unrelated domains before opening WebSockets.
@rfoust rfoust marked this pull request as ready for review June 27, 2026 01:01
@rfoust rfoust requested a review from a team as a code owner June 27, 2026 01:01
Copilot AI review requested due to automatic review settings June 27, 2026 01:01

Copilot AI 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.

Pull request overview

Fixes KiwiSDR proxy redirect handling during /status preflight so that AetherSDR can connect through Kiwi’s proxy→proxy2 migration while validating redirects before using the resulting host/port as the WebSocket transport authority (boundary input validation).

Changes:

  • Adds manual redirect handling for /status preflight, with allowlisted redirect validation (same-host, plus proxy↔proxy2 receiver-alias migration).
  • Uses the final validated /status URL to derive the WebSocket scheme/host/port for subsequent WebSocket connections.
  • Tracks redirect counts and persists a resolved WebSocket port separately from the original endpoint port.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/core/KiwiSdrClient.h Adjusts preflight API and adds state for resolved WebSocket port + redirect tracking.
src/core/KiwiSdrClient.cpp Implements manual redirect following/validation and derives WebSocket transport from the final validated /status URL.

Comment thread src/core/KiwiSdrClient.cpp Outdated
Comment on lines +334 to +395
QString canonicalRedirectHost(QString host)
{
host = host.trimmed().toLower();
while (host.endsWith(QLatin1Char('.'))) {
host.chop(1);
}
return host;
}

QString kiwiProxyReceiverAlias(const QString& host)
{
constexpr QLatin1StringView kProxySuffix{".proxy.kiwisdr.com"};
constexpr QLatin1StringView kProxy2Suffix{".proxy2.kiwisdr.com"};
if (host.endsWith(kProxySuffix) && host.size() > kProxySuffix.size()) {
return host.left(host.size() - kProxySuffix.size());
}
if (host.endsWith(kProxy2Suffix) && host.size() > kProxy2Suffix.size()) {
return host.left(host.size() - kProxy2Suffix.size());
}
return {};
}

bool isAllowedKiwiStatusRedirect(const QUrl& from,
const QUrl& to,
QString* detail)
{
const QString scheme = to.scheme().toLower();
if (scheme != QStringLiteral("http") && scheme != QStringLiteral("https")) {
if (detail) {
*detail = QStringLiteral("unsupported scheme %1").arg(to.scheme());
}
return false;
}

const QString fromHost = canonicalRedirectHost(from.host());
const QString toHost = canonicalRedirectHost(to.host());
if (fromHost.isEmpty() || toHost.isEmpty()) {
if (detail) {
*detail = QStringLiteral("missing redirect host");
}
return false;
}

if (fromHost == toHost) {
return true;
}

// Kiwi's public proxy can migrate one receiver alias from proxy to proxy2.
// Keep that exception exact so a status page cannot move the WebSocket
// target to an unrelated host after the policy preflight succeeds.
const QString fromAlias = kiwiProxyReceiverAlias(fromHost);
const QString toAlias = kiwiProxyReceiverAlias(toHost);
if (!fromAlias.isEmpty() && fromAlias == toAlias) {
return true;
}

if (detail) {
*detail = QStringLiteral("cross-domain redirect from %1 to %2")
.arg(fromHost, toHost);
}
return false;
}
Comment on lines +634 to +640
if (redirectUrl.isEmpty()) {
setState(
State::Error,
tr("This KiwiSDR's status page redirected without a target, so AetherSDR won't connect."));
cleanupSockets();
return;
}
@rfoust rfoust self-assigned this Jun 27, 2026
@rfoust rfoust requested a review from a team as a code owner June 27, 2026 01:15

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @rfoust — this is a clean, well-scoped fix. The manual redirect handling is fail-closed by default, the proxy→proxy2 alias exception is narrow and exact, and extracting the policy into KiwiSdrRedirectPolicy with a dedicated unit test is exactly the right call. The transport-resolution logic correctly threads the final validated /status host/port/scheme into m_webSocketPort so the WebSocket opens against the right endpoint. Nice work on the boundary validation (Principle VII).

A few notes, all minor / optional:

Reviewed and confirmed correct:

  • Reply lifecycle is leak-free — each startStatusPreflight assigns a fresh m_statusReply and the prior reply is deleteLater()'d; the redirect-count reset on the https retry is handled.
  • The 3xx block is correctly placed before if (ok), which is necessary since ManualRedirectPolicy makes a 3xx finish with NoError.
  • Per-hop validation (requestUrlnextUrl) chains correctly through the proxy:8073 → proxy2 → proxy2:8073 reproduction, and proxyReceiverAlias correctly rejects a bare proxy.kiwisdr.com (no receiver prefix) via the size() > guard — good that the test pins this.

On the two Copilot findings — both appear already addressed in the final diff:

  1. The helpers were extracted into a testable KiwiSdrRedirectPolicy unit (kiwi_sdr_redirect_policy_test.cpp) covering same-host, proxy↔proxy2, and rejected cross-domain/scheme cases. Resolved.
  2. The 3xx-without-target message now reads "unsupported HTTP %1 without a redirect target" and includes the status code, so the 304 wording concern is handled. Resolved.

Optional polish (non-blocking):

  • The rejection path collapses three distinct reasons (cross-domain, unsupported scheme, too many redirects) into one user-facing string — "redirected outside its trusted KiwiSDR proxy host." For the >= kStatusPreflightMaxRedirects case that message is slightly inaccurate (the host may be fine, the chain is just too long). The precise reason is already in the qCWarning log, so this is cosmetic, but a distinct message for the redirect-limit case would read better.
  • isAllowedStatusRedirect permits a same-host https→http downgrade. Given the trust is already scoped to the Kiwi proxy host this isn't a real exposure, but if you wanted to be strict you could forbid downgrading the scheme on same-host hops. Entirely optional.

No blockers from me. LGTM.


🤖 aethersdr-agent · cost: $2.1574 · model: claude-opus-4-8

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved. Independently traced the redirect/SSRF surface and it's genuinely fail-closed (Principle VII):

  • Policy is suffix-anchored21042.proxy.kiwisdr.com.evil.com and bare proxy.kiwisdr.com both reject; the cross-host exception can only ever target <same-id>.proxy[2].kiwisdr.com, so a status page can't repoint the WebSocket to an unrelated domain. Non-http(s) schemes rejected up front.
  • Enforcement is correctManualRedirectPolicy makes the policy authoritative; per-hop validation on requestUrl → resolved(redirectUrl); the alias is preserved across hops so a multi-hop chain stays confined; max-redirect cap (8) kills the proxy↔proxy2 bounce; the WS authority is taken from the policy-validated final URL; 3xx handled before if (ok) (correct for manual-redirect NoError finishes).
  • Well-tested — canonical normalization, same-host, proxy↔proxy2 both ways, cross-domain reject, mismatched-id reject, scheme reject.

Two cosmetic nits (generic "too many redirects" message; permitted same-host https→http downgrade) are non-blocking. Thanks @rfoust — clean, security-conscious fix for the proxy split.

@ten9876 ten9876 merged commit c34ea53 into aethersdr:main Jun 27, 2026
6 checks passed
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.

Some Kiwis Fail to Connect

3 participants