Files
shopdb-flask/plugins/tools/frontend/views/SecureSendCreate.vue
cproudlock a3811740bb
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / backend (push) Has been cancelled
Build a secret's link from the site's URL, not the browser's
Reported from production: clicking a secret link gave an IIS 404, and the link
read /s/<token> where the site lives at /shopdb/s/<token>.

The link was assembled from window.location.origin, which is scheme and host
only. site_base_url already exists for this and says so in its own description -
"used for QR codes and any absolute link the app emits" - with getSiteBaseUrl()
as the helper the QR pages use. This reached past all of it.

Both halves were wrong, and the second is worse because it fails silently:

  MOUNT PATH. A site served under /shopdb or /ops carries that in front of
  every route. Leaving it off is the reported 404.

  HOST. Whoever creates a secret may have reached the app by an IP, a short
  hostname, or a name that resolves in one VLAN, and the recipient opens the
  link from somewhere else entirely. The configured site URL is the one address
  known to work for both of them.

The shape of this failure is what makes it worth spelling out: the secret was
created SUCCESSFULLY and the sender was handed a link that could never work, so
the failure landed on the recipient, the sender had no reason to doubt it, and
the secret expired unread. A 500 would have been kinder.

Falls back to the browsing origin when a site has not set site_base_url, which
is what every other absolute link here does.

BASE_URL is injected at build time, so the subpath half of this cannot show up
in dev and only appears on a subpath deployment.
2026-08-21 15:19:31 -04:00

208 lines
7.6 KiB
Vue

<template>
<div>
<div class="page-header">
<h1>Secure Send</h1>
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
</div>
<div class="card intro">
<p>
Hand somebody a password without leaving it in a chat window. The text is
encrypted in this browser and the key travels in the link, never to the
server - so nobody here can read what you send, including an administrator.
</p>
<p class="muted">
The link works once, then the secret is destroyed. For passwords and short
text only, not documents.
</p>
</div>
<div v-if="!supported" class="card control-note">
This browser cannot encrypt in page (Web Crypto is unavailable, which
usually means the site is not being served over HTTPS). Secure send needs
it, because the whole point is that the server never sees your text.
</div>
<template v-else-if="!link">
<div class="card">
<label class="field">
<span class="field-label">Secret</span>
<textarea
v-model="plaintext"
class="form-control"
rows="4"
placeholder="Password, connection string, short note..."
@paste="onPaste"
></textarea>
</label>
<p class="counter" :class="{ over: plaintext.length > MAX_PLAINTEXT }">
{{ plaintext.length }} / {{ MAX_PLAINTEXT }} characters
</p>
<p v-if="refusal" class="control-note">{{ refusal }}</p>
<div class="options">
<label class="field">
<span class="field-label">Expires after</span>
<select v-model.number="expiryhours" class="form-control">
<option :value="1">1 hour</option>
<option :value="8">8 hours</option>
<option :value="24">1 day</option>
<option :value="72">3 days</option>
<option :value="168">7 days</option>
</select>
</label>
<label class="field">
<span class="field-label">Can be opened</span>
<select v-model.number="views" class="form-control">
<option :value="1">Once</option>
<option :value="2">Twice</option>
<option :value="3">3 times</option>
<option :value="5">5 times</option>
</select>
</label>
</div>
<button class="btn btn-primary" :disabled="busy || !!refusal" @click="createLink">
{{ busy ? 'Encrypting...' : 'Create link' }}
</button>
<p v-if="error" class="control-note">{{ error }}</p>
</div>
</template>
<div v-else class="card">
<h3>Your link</h3>
<p class="muted">
Send this to the person who needs it. It opens
{{ views === 1 ? 'once' : views + ' times' }} and expires {{ expiryLabel }}.
</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>
</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.
</p>
<button class="btn btn-secondary" @click="reset">Send another</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { toolsApi } from '../api'
import { withBase } from '@/utils/basePath'
import { getSiteBaseUrl } from '@/utils/siteSettings'
import {
sealSecret, refuseReason, pasteCarriesFiles, cryptoAvailable, MAX_PLAINTEXT,
} from '../securesend'
const supported = cryptoAvailable()
const plaintext = ref('')
const expiryhours = ref(24)
const views = ref(1)
const busy = ref(false)
const error = ref('')
const link = ref('')
const copied = ref(false)
const linkInput = ref(null)
// Only nag once there is something to nag about: an empty box is not a mistake.
const refusal = computed(() => (plaintext.value ? refuseReason(plaintext.value) : null))
const expiryLabel = computed(() => {
const hours = expiryhours.value
if (hours < 24) return `in ${hours} hour${hours === 1 ? '' : 's'}`
const days = hours / 24
return `in ${days} day${days === 1 ? '' : 's'}`
})
function onPaste(event) {
// A pasted FILE is the easiest wrong path into a tool that is not a file
// transfer. Refuse it here rather than letting its bytes become "text".
if (pasteCarriesFiles(event)) {
event.preventDefault()
error.value = 'Secure send takes text, not files. Paste the password itself.'
}
}
async function createLink() {
error.value = ''
busy.value = true
try {
const sealed = await sealSecret(plaintext.value)
const response = await toolsApi.createSecret({
ciphertext: sealed.ciphertext,
iv: sealed.iv,
expiryhours: expiryhours.value,
views: views.value,
})
const token = response.data.data.token
// THE SITE'S CONFIGURED URL, not the one this browser happens to be using.
// site_base_url exists for exactly this - "used for QR codes and any
// absolute link the app emits" - because somebody creating a secret may
// have reached the app by an IP, a short hostname, or a name that only
// resolves inside one VLAN, and the recipient has to open the link from
// wherever they are. It falls back to the browsing origin when a site has
// not set one.
//
// withBase for the path: a site served under /shopdb or /ops carries that
// in front of every route, and leaving it off produced a 404 from IIS for a
// secret that existed - with the sender shown a link that could never work.
const origin = (await getSiteBaseUrl()).replace(/\/+$/, '')
const path = withBase(`/s/${token}`)
// The key rides in the FRAGMENT. Browsers never send it, so it reaches the
// recipient and nowhere else.
link.value = `${origin}${path}#${sealed.fragmentKey}`
// Drop the plaintext as soon as it is sealed: no reason to keep it in memory
// behind the link that replaced it.
plaintext.value = ''
} catch (err) {
error.value = err.response?.data?.data?.error?.message || 'Could not create the link.'
} finally {
busy.value = false
}
}
async function copyLink() {
try {
await navigator.clipboard.writeText(link.value)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch (err) {
// Clipboard access is refused in some hardened configurations. Select the
// text instead so Ctrl+C still works rather than leaving a dead button.
selectAll()
}
}
function selectAll() {
linkInput.value?.select()
}
function reset() {
link.value = ''
error.value = ''
copied.value = false
}
</script>
<style scoped>
.intro p { margin: 0 0 0.5rem; }
.muted { color: var(--text-light); font-size: 0.9rem; }
.field { display: block; margin-bottom: 1rem; }
.field-label { display: block; font-size: 0.8125rem; color: var(--text-light); margin-bottom: 4px; }
.options { display: flex; gap: 1rem; flex-wrap: wrap; }
.options .field { flex: 1 1 12rem; }
.counter { font-size: 0.8125rem; color: var(--text-light); margin: 0 0 0.5rem; }
.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; }
.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; }
</style>