[Client] Make the link-connection gates port-aware and keyboard-safe

Second-pass review notes for the shared-deck link flow (Cockatrice#7244):

- FlowWidget arrow-key navigation is opt-in via addNavigableWidget, so
  combo/spin controls on the analytics flows keep their own arrow keys
- isConnectedTo and the open-deck/join-game preconditions compare the
  configured server port alongside the host, so a same-host/different-port
  link cannot resolve its share token or game id on the wrong instance
- the link sign-in dialog reuses an existing server entry's saved name
  instead of renaming it to the raw hostname
- skipStartupAutoConnect is cleared once the launch chain connects, so a
  later mid-session declined link cannot fire the startup fallback
- the plain-launch path of SingleInstanceManager no longer blocks on the
  primary's ACK
- link- and server-supplied text is html-escaped in the confirm prompts and
  shared-deck preview so markup cannot spoof the shown messages
This commit is contained in:
Lukas Brübach 2026-09-20 19:58:09 +02:00
parent d21dfccbef
commit 20079dc471
10 changed files with 89 additions and 23 deletions

View file

@ -19,12 +19,17 @@ bool IntentJoinServerGame::checkPrecondition() const
if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) {
return false; return false;
} }
// serverName() reflects the server the client was configured to connect to, // serverName()/serverPort() reflect the server the client was configured
// which may differ from the actual TCP peer (e.g. when connecting through a // to connect to, which may differ from the actual TCP peer (e.g. when
// proxy), so only the hostname is compared here. // connecting through a proxy), so compare those configured values. A link
// naming the same host on another port is a different server and must not
// reuse the session there.
if (remoteClient->serverName().compare(context->roomContext.serverContext.hostname, Qt::CaseInsensitive) != 0) { if (remoteClient->serverName().compare(context->roomContext.serverContext.hostname, Qt::CaseInsensitive) != 0) {
return false; return false;
} }
if (QString::number(remoteClient->serverPort()) != context->roomContext.serverContext.port) {
return false;
}
if (!tabSupervisor->getRoomTabs().contains(context->roomContext.roomId)) { if (!tabSupervisor->getRoomTabs().contains(context->roomContext.roomId)) {
return false; return false;

View file

@ -60,8 +60,19 @@ void IntentGetLoginCredentials::onPreconditionNotSatisfied()
if (dialog.savePassword() && !context->username.isEmpty()) { if (dialog.savePassword() && !context->username.isEmpty()) {
ServersSettings &servers = SettingsCache::instance().servers(); ServersSettings &servers = SettingsCache::instance().servers();
servers.addNewServer(context->hostname, context->hostname, context->port, context->username, context->password, // The host may already be saved under a friendly name (e.g. a public-server
true); // list entry) with no credentials; reuse that name instead of overwriting
// it with the raw hostname when addNewServer updates the entry in place.
QString saveName = context->hostname;
const int existingIndex = servers.findServerIndex(context->hostname, context->port);
if (existingIndex >= 0) {
saveName =
servers.getValue(QString("saveName%1").arg(existingIndex), "server", "server_details").toString();
if (saveName.isEmpty()) {
saveName = context->hostname;
}
}
servers.addNewServer(saveName, context->hostname, context->port, context->username, context->password, true);
} }
emitFinished(); emitFinished();

View file

@ -34,10 +34,15 @@ bool IntentOpenSharedDeck::checkPrecondition() const
if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) {
return false; return false;
} }
// serverName() reflects the server the client was configured to connect to, // serverName()/serverPort() reflect the server the client was configured
// which may differ from the actual TCP peer (e.g. when connecting through a // to connect to, which may differ from the actual TCP peer (e.g. when
// proxy), so only the hostname is compared here. // connecting through a proxy), so compare those configured values. The
return remoteClient->serverName().compare(context->serverContext.hostname, Qt::CaseInsensitive) == 0; // share token must be resolved against the host the link named — a link to
// the same host on another port is a different server.
if (remoteClient->serverName().compare(context->serverContext.hostname, Qt::CaseInsensitive) != 0) {
return false;
}
return QString::number(remoteClient->serverPort()) == context->serverContext.port;
} }
void IntentOpenSharedDeck::onPreconditionSatisfied() void IntentOpenSharedDeck::onPreconditionSatisfied()

View file

@ -208,9 +208,13 @@ Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, PendingInt
// taking the session anywhere it isn't already, naming the host we would // taking the session anywhere it isn't already, naming the host we would
// connect to. Remember the link's target when it moves us away from a live // connect to. Remember the link's target when it moves us away from a live
// session so a failed or cancelled chain can restore the session it left. // session so a failed or cancelled chain can restore the session it left.
// The hostname is link-supplied and percent-decoded, so escape it: QMessageBox
// renders AutoText, and markup in a hostname would otherwise flip the whole
// prompt to rich text and let a link pad the message the user is shown.
const bool alreadyConnected = isConnectedTo(ctx->serverContext.hostname, ctx->serverContext.port); const bool alreadyConnected = isConnectedTo(ctx->serverContext.hostname, ctx->serverContext.port);
if (!alreadyConnected) { if (!alreadyConnected) {
const QString target = QStringLiteral("%1:%2").arg(ctx->serverContext.hostname, ctx->serverContext.port); const QString target =
QStringLiteral("%1:%2").arg(ctx->serverContext.hostname.toHtmlEscaped(), ctx->serverContext.port);
if (client->getStatus() == StatusLoggedIn) { if (client->getStatus() == StatusLoggedIn) {
const QString current = const QString current =
@ -273,12 +277,14 @@ Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, PendingInt
bool IntentUrlParser::isConnectedTo(const QString &hostname, const QString &port) const bool IntentUrlParser::isConnectedTo(const QString &hostname, const QString &port) const
{ {
Q_UNUSED(port); // serverName() reflects the server the client was configured to connect to,
// Deliberately hostname-only (no port): the intents' preconditions apply the // which may differ from the actual TCP peer (e.g. when connecting through a
// same rule, so a link to the same host on another port still connects // proxy), so compare the configured host and port — exactly what a link
// rather than silently reusing an existing session on a different server. // names. A link to the same host on another port is a different server and
// must not silently reuse an existing session there.
RemoteClient *client = mainWindow->getRemoteClient(); RemoteClient *client = mainWindow->getRemoteClient();
return client->getStatus() == StatusLoggedIn && client->serverName().compare(hostname, Qt::CaseInsensitive) == 0; return client->getStatus() == StatusLoggedIn && client->serverName().compare(hostname, Qt::CaseInsensitive) == 0 &&
QString::number(client->serverPort()) == port;
} }
void IntentUrlParser::startNextChain() void IntentUrlParser::startNextChain()

View file

@ -33,7 +33,8 @@ SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent,
colorIdentityWidget = new ColorIdentityWidget(this, colorIdentity); colorIdentityWidget = new ColorIdentityWidget(this, colorIdentity);
colorIdentityWidget->setVisible(!colorIdentity.isEmpty()); colorIdentityWidget->setVisible(!colorIdentity.isEmpty());
gameFormatLabel = new QLabel(gameFormat, this); // gameFormat is server-supplied and the QLabel renders AutoText, so escape it.
gameFormatLabel = new QLabel(gameFormat.toHtmlEscaped(), this);
gameFormatLabel->setAlignment(Qt::AlignCenter); gameFormatLabel->setAlignment(Qt::AlignCenter);
gameFormatLabel->setVisible(!gameFormat.isEmpty()); gameFormatLabel->setVisible(!gameFormat.isEmpty());

View file

@ -25,7 +25,10 @@ DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent,
auto *mainLayout = new QVBoxLayout(this); auto *mainLayout = new QVBoxLayout(this);
auto *titleLabel = new QLabel(tr("Share: %1").arg(shareName.isEmpty() ? tr("Untitled") : shareName), this); // shareName and serverText come from the share server, so escape them: the
// QLabels render AutoText and markup would otherwise be shown as rich text.
auto *titleLabel =
new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this);
QFont titleFont = titleLabel->font(); QFont titleFont = titleLabel->font();
titleFont.setBold(true); titleFont.setBold(true);
titleFont.setPointSize(titleFont.pointSize() + 2); titleFont.setPointSize(titleFont.pointSize() + 2);
@ -33,7 +36,7 @@ DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent,
mainLayout->addWidget(titleLabel); mainLayout->addWidget(titleLabel);
if (!serverText.isEmpty()) { if (!serverText.isEmpty()) {
mainLayout->addWidget(new QLabel(tr("From %1").arg(serverText), this)); mainLayout->addWidget(new QLabel(tr("From %1").arg(serverText.toHtmlEscaped()), this));
} }
if (expiresAt > 0) { if (expiresAt > 0) {
@ -57,7 +60,7 @@ DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent,
auto *tile = new SharedDeckPreviewWidget( auto *tile = new SharedDeckPreviewWidget(
this, querier, QString::fromStdString(item.name()), QString::fromStdString(item.banner_card()), this, querier, QString::fromStdString(item.name()), QString::fromStdString(item.banner_card()),
QString::fromStdString(item.color_identity()), QString::fromStdString(item.game_format()), tags.join(", ")); QString::fromStdString(item.color_identity()), QString::fromStdString(item.game_format()), tags.join(", "));
flowWidget->addWidget(tile); flowWidget->addNavigableWidget(tile);
tiles.append(tile); tiles.append(tile);
itemIds.append(item.id()); itemIds.append(item.id());
} }

View file

@ -81,13 +81,30 @@ FlowWidget::FlowWidget(QWidget *parent,
/** /**
* @brief Adds a widget to the flow layout within the FlowWidget. * @brief Adds a widget to the flow layout within the FlowWidget.
* *
* The widget is filtered for arrow-key events so keyboard navigation between * Plain widgets are not filtered for arrow keys: intercepting them would steal
* the flow items keeps working even when the flow sits inside a QScrollArea, * Up/Down/Left/Right from controls that use them (combo boxes, spin boxes
* which swallows arrow keys before they can reach FlowWidget::keyPressEvent. * etc.). Widgets that want keyboard navigation between flow items must be
* added via addNavigableWidget instead.
* *
* @param widget_to_add The widget to add to the flow layout. * @param widget_to_add The widget to add to the flow layout.
*/ */
void FlowWidget::addWidget(QWidget *widget_to_add) void FlowWidget::addWidget(QWidget *widget_to_add)
{
flowLayout->addWidget(widget_to_add);
}
/**
* @brief Adds a widget and routes its arrow keys to FlowWidget focus navigation.
*
* The widget is filtered for arrow-key events so keyboard navigation between
* the flow items keeps working even when the flow sits inside a QScrollArea,
* which swallows arrow keys before they can reach FlowWidget::keyPressEvent.
* Only widgets added through this method are affected; anything that needs its
* own arrow keys should use plain addWidget.
*
* @param widget_to_add The widget to add to the flow layout.
*/
void FlowWidget::addNavigableWidget(QWidget *widget_to_add)
{ {
widget_to_add->installEventFilter(this); widget_to_add->installEventFilter(this);
flowLayout->addWidget(widget_to_add); flowLayout->addWidget(widget_to_add);

View file

@ -30,6 +30,7 @@ public:
Qt::ScrollBarPolicy verticalPolicy); Qt::ScrollBarPolicy verticalPolicy);
void addWidget(QWidget *widget_to_add); void addWidget(QWidget *widget_to_add);
void addNavigableWidget(QWidget *widget_to_add);
void insertWidgetAtIndex(QWidget *toInsert, int index); void insertWidgetAtIndex(QWidget *toInsert, int index);
void removeWidget(QWidget *widgetToRemove) const; void removeWidget(QWidget *widgetToRemove) const;
void clearLayout(); void clearLayout();

View file

@ -939,7 +939,16 @@ void MainWindow::onUrlChainFinished(bool connected)
// chain ended without connecting (declined, invalid, offline), fall back to // chain ended without connecting (declined, invalid, offline), fall back to
// the startup connection so the activation launch still behaves like a // the startup connection so the activation launch still behaves like a
// normal launch. // normal launch.
if (connected || !skipStartupAutoConnect || getRemoteClient()->getStatus() != StatusDisconnected) { if (connected) {
// The launch link connected, so the startup fallback has served its
// purpose: drop the skip so a later mid-session link that ends declined
// or offline cannot silently fire auto-connect or applyStartupDestination
// again.
skipStartupAutoConnect = false;
return;
}
if (!skipStartupAutoConnect || getRemoteClient()->getStatus() != StatusDisconnected) {
return; return;
} }

View file

@ -92,6 +92,14 @@ SingleInstanceManager::ForwardResult SingleInstanceManager::forwardToPrimary(con
socket.flush(); socket.flush();
socket.waitForBytesWritten(1000); socket.waitForBytesWritten(1000);
// A plain launch has nothing for the primary to act on, so there is nothing
// to acknowledge. Waiting here would block the new instance for seconds if
// the primary is busy in a modal dialog, so only the activation path (which
// needs the ACK to avoid stealing a live primary's socket) waits below.
if (filesToSend.isEmpty()) {
return ForwardResult::Delivered;
}
// Only report a successful hand-off once the primary has acknowledged that // Only report a successful hand-off once the primary has acknowledged that
// it actually read the payload. A socket that connects but is still working // it actually read the payload. A socket that connects but is still working
// on an earlier payload is alive but busy, not dead: give it more room // on an earlier payload is alive but busy, not dead: give it more room