Stop the one-line install handing a standard user a way in
The bootstrap command staged its script into C:\Windows\Temp and then ran it elevated. That directory grants Users create-file with CREATOR OWNER full control, so somebody without admin rights can pre-plant the exact path the elevated run is about to execute. It now stages under Program Files, creates the directory with an explicit DACL when it is missing, and refuses to run a staged file that is not owned by an admin principal. A failed hardening is loud rather than silent: the run stops instead of carrying on with a file it could not protect. The token moved out of the query string into a header. In a query string it lands verbatim in the reverse proxy and IIS logs, which is a credential written to a file nobody thinks of as a secret store. The query form is still accepted, because bays provisioned before this change are holding install commands that use it, and it logs that it was used - without logging the token - so a site can see who still needs a re-issued command before turning it off with plugin.endpoints.bootstrapquerytoken. Two smaller things this also closes: A request with no token at all answered 400 naming the header, which told an anonymous caller the state of that switch and contradicted the module's own promise that anything unusable answers 404. No token is now 404. The 400 is kept for a token genuinely presented in the query form while the query form is off, which is a caller who needs to know their command is stale. The rollout documentation was republishing the vulnerable one-liner verbatim, so a tech following the runbook reinstated exactly what this fixes. Both documents now carry the current command. Also corrects the operator-facing copy for the token, which named a query argument and a header that do not exist: it is ?t= and X-Endpoint-Bootstrap-Token, per bootstrap.py's own constants. Anybody following the old text got a 404 with no reason given. Both switches added in this range are now declared with the other plugin settings and reachable from their settings page. Read through Setting.get but never declared, they were rows that did not exist until someone wrote one by hand, which on an air-gapped box means the feature was unreachable.
This commit is contained in:
@@ -482,6 +482,31 @@
|
||||
Settings > Endpoints > Enrollment.
|
||||
</p>
|
||||
|
||||
<!-- The deprecated query form of the bootstrap token. Rendered here
|
||||
because this is the page that mints the command the setting
|
||||
governs: turning it off invalidates every command already handed
|
||||
out that carries ?t=. -->
|
||||
<label class="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle-querytoken"
|
||||
:checked="querytokenallowed"
|
||||
:disabled="querytokenbusy"
|
||||
@change="onQueryTokenToggle($event)"
|
||||
/>
|
||||
<span>
|
||||
<strong>Accept the token in the URL.</strong> On by default:
|
||||
the pasted one-line command and the Download .ps1 link above both
|
||||
send the token as <code>?t=</code>, which is the only form a
|
||||
browser download link can use. The token lands in the IIS and
|
||||
proxy logs that way, so the header form
|
||||
(<code>X-Endpoint-Bootstrap-Token</code>) replaces it. Turn this off
|
||||
and every install command still in a technician's hands stops
|
||||
working immediately - re-issue them first.
|
||||
</span>
|
||||
</label>
|
||||
<p v-if="querytokenerror" class="field-error">{{ querytokenerror }}</p>
|
||||
|
||||
<table v-if="bootstraptokens.length" class="versions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -764,6 +789,15 @@ const bootstraptokens = ref([])
|
||||
const bootstrapmessage = ref('')
|
||||
const bootstraperror = ref('')
|
||||
|
||||
// plugin.endpoints.bootstrapquerytoken, read and written through the core
|
||||
// settings API: the endpoints plugin has no settings route of its own for it,
|
||||
// and the key is declared in get_settings_defaults so the upsert on PUT gets
|
||||
// the boolean valuetype and the description from that declaration.
|
||||
const QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken'
|
||||
const querytokenallowed = ref(true)
|
||||
const querytokenbusy = ref(false)
|
||||
const querytokenerror = ref('')
|
||||
|
||||
function agentnextpininvalid() {
|
||||
const pin = agentnextserverpin.value
|
||||
return !!pin && !/^[0-9a-f]{64}$/.test(pin)
|
||||
@@ -863,6 +897,7 @@ onMounted(async () => {
|
||||
}
|
||||
loadEnrollment()
|
||||
loadBootstrapTokens()
|
||||
loadQueryTokenSetting()
|
||||
try {
|
||||
const response = await api.get('/endpoints/siteagent')
|
||||
const status = response.data.data || {}
|
||||
@@ -878,6 +913,53 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
async function loadQueryTokenSetting() {
|
||||
try {
|
||||
const response = await api.get(`/settings/${QUERY_TOKEN_SETTING}`)
|
||||
// The backend reads it as bool(Setting.get(key, True)), so only an
|
||||
// explicit false is off. A row seeded as boolean comes back as a real
|
||||
// false; an older row that was created as a string comes back as 'false'.
|
||||
const value = response.data.data?.value
|
||||
querytokenallowed.value = !(value === false || value === 'false')
|
||||
} catch (loadError) {
|
||||
// 404 = the row was never seeded (plugin installed before the key was
|
||||
// declared). The backend default is on, so show on.
|
||||
if (loadError.response?.status !== 404) console.error(loadError)
|
||||
querytokenallowed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function onQueryTokenToggle(event) {
|
||||
const turningOn = event.target.checked
|
||||
if (!turningOn) {
|
||||
const answer = prompt(
|
||||
'Stop accepting the token in the URL?\n\n'
|
||||
+ 'Every install command already handed out uses ?t= and stops '
|
||||
+ 'working at once, including the Download .ps1 links. Re-issue them '
|
||||
+ 'before turning this off.\n\n'
|
||||
+ 'Type OFF to confirm.')
|
||||
if (!answer || answer.trim().toUpperCase() !== 'OFF') {
|
||||
event.target.checked = true
|
||||
return
|
||||
}
|
||||
}
|
||||
querytokenbusy.value = true
|
||||
querytokenerror.value = ''
|
||||
try {
|
||||
// PUT upserts, so this works on a site whose row was never seeded.
|
||||
await api.put(`/settings/${QUERY_TOKEN_SETTING}`, { value: turningOn })
|
||||
querytokenallowed.value = turningOn
|
||||
} catch (saveError) {
|
||||
querytokenerror.value = saveError.response?.status === 403
|
||||
? 'Only an administrator can change this setting'
|
||||
: 'Could not save the setting'
|
||||
event.target.checked = querytokenallowed.value
|
||||
console.error(saveError)
|
||||
} finally {
|
||||
querytokenbusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBootstrapTokens() {
|
||||
try {
|
||||
const response = await api.get('/endpoints/siteagent/bootstrap/tokens')
|
||||
|
||||
Reference in New Issue
Block a user