Secure Send hands over a message, not a bare URL
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:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user