Secure Send hands over a message, not a bare URL
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Successful in 3m52s

A link on its own invites being pasted onto text that is already in the chat
box, and every chat client turns the selection into the link's caption. The
secret then travels as the visible message with the secure link wrapped around
it, protecting nothing.

This is not hypothetical. Six LAPS passwords went out that way in production -
machine names and passwords as the caption, the correct /s/<token>#<key> URL
underneath, doing nothing at all.

Copy message puts a whole sentence on the clipboard: what the link is, how many
opens it has, when it expires, and then the URL. A sentence has nowhere to
attach itself - pasted into an empty box it is a message, pasted over a
selection it replaces it rather than decorating it. Plain text on purpose; a
rich-text flavour is exactly what would let a secret ride along as markup.

The success card now says to delete the password from the chat box before
pasting. The existing hint explains that the key cannot be recovered, which is
true and does not stop anybody pasting the password itself.

Nothing about the link was wrong and nothing about it changed: the URL carries
the token, the key rides in the fragment the browser never sends, the plaintext
is dropped the moment it is sealed, and redeeming stays a POST so a preview
cannot spend it.
This commit is contained in:
cproudlock
2026-08-28 11:39:21 -04:00
parent 5e7e39a24c
commit dd3db50460
2 changed files with 75 additions and 1 deletions

View File

@@ -128,6 +128,17 @@ ADR-007 and ADR-002.
### Fixed
- **Secure Send now hands you a message, not a bare URL.** A link on its own
invites being pasted onto text already sitting in the chat box, and a chat
client turns the selection into the link's caption - so the secret travels as
the visible message and the link beside it protects nothing. That happened in
production: six LAPS passwords sent as the caption of a link whose whole job
was to keep them out of the chat. Copy message puts a sentence on the
clipboard instead, which has nowhere to attach itself, and the success card
now says plainly to delete the password from the chat box before pasting.
Nothing about the link itself was wrong - the URL carries only the token, the
key rides in the fragment, and neither ever reached the message.
- **Ticking a section off crossed out its title and nothing else.** The strike
now covers everything under that heading, including its sub-sections, which
is what "this section is done" means to somebody who ticked the thing that

View File

@@ -94,8 +94,30 @@
</p>
<div class="link-row">
<input ref="linkInput" class="form-control mono" :value="link" readonly @focus="selectAll" />
<button class="btn btn-primary" @click="copyLink">{{ copied ? 'Copied' : 'Copy' }}</button>
<button class="btn btn-primary" @click="copyLink">{{ copied ? 'Copied' : 'Copy link' }}</button>
<!--
Copies a whole sentence, not the bare URL. Pasted into an EMPTY chat
box there is nothing selected for the link to attach itself to - which
is the failure this button exists to prevent. See copyMessage().
-->
<button class="btn btn-secondary" @click="copyMessage">
{{ messagecopied ? 'Copied' : 'Copy message' }}
</button>
</div>
<!--
The warning that matters, stated where the mistake gets made. The hint
below is about the key being unrecoverable, which is a different point
and does not stop anybody pasting the password itself.
-->
<div class="alert-send">
<strong>Send the link, not the secret.</strong>
If the password is already typed in a chat box, delete it before pasting
this. Pasting a link over selected text turns that text into the link's
caption, and the secret then travels in the message itself - the link
beside it protects nothing.
</div>
<p class="control-hint">
This is the only time the link is shown - it is not stored anywhere you can
look it up, and the key in it exists nowhere else.
@@ -142,6 +164,7 @@ const busy = ref(false)
const error = ref('')
const link = ref('')
const copied = ref(false)
const messagecopied = ref(false)
const linkInput = ref(null)
// Only nag once there is something to nag about: an empty box is not a mistake.
@@ -222,6 +245,35 @@ async function copyLink() {
}
}
/**
* The link with a sentence around it.
*
* A bare URL invites being pasted onto text that is already in the chat box,
* and a chat client turns the selection into the link's caption - so the secret
* travels as the visible message and the link protects nothing. It has happened
* in production: six LAPS passwords sent as the caption of a link whose whole
* job was to keep them out of the chat.
*
* A sentence has nowhere to attach itself. Pasted into an empty box it is a
* message; pasted over a selection it replaces it rather than decorating it.
* Plain text on purpose - a rich-text flavour is what would let a secret ride
* along as markup.
*/
async function copyMessage() {
const opens = views.value === 1
? 'It opens once and then destroys itself'
: `It opens ${views.value} times and then destroys itself`
const message =
`Secure link - ${opens}, and expires ${expiryLabel.value}: ${link.value}`
try {
await navigator.clipboard.writeText(message)
messagecopied.value = true
setTimeout(() => { messagecopied.value = false }, 2000)
} catch (err) {
selectAll()
}
}
function selectAll() {
linkInput.value?.select()
}
@@ -245,6 +297,17 @@ function reset() {
.counter.over { color: var(--warning); font-weight: 600; }
.control-note { color: var(--warning); font-size: 0.875rem; margin: 8px 0; }
.control-hint { color: var(--text-light); font-size: 0.8125rem; margin: 8px 0 12px; }
/* Loud enough to be read before the paste, quiet enough not to look like an
error on a page where nothing went wrong. */
.alert-send {
border-left: 3px solid var(--warning);
background: var(--bg-card-solid);
padding: 0.6rem 0.9rem;
margin: 0.75rem 0;
font-size: 0.875rem;
line-height: 1.5;
}
.alert-send strong { display: block; margin-bottom: 2px; }
.link-row { display: flex; gap: 8px; align-items: center; margin: 0.5rem 0; }
.link-row .form-control { flex: 1; }
.mono { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 0.875rem; }