Admit kiosk callers by network position, not only by device token
The device token assumes a handful of fixed screens an administrator provisions once. That is not how these kiosks are used: most people open the page on their own PC, and there is no login, so per-browser provisioning would mean touching every PC on the floor and losing it again on the next reimage or cleared profile. A screen in that state did not look refused either - the USB kiosk rendered a working page whose every call answered 404. Both kiosks now admit a caller on either ground, the same shape the GE-Enforce client endpoints already use: the per-device token, or a source address inside an allowlist. Wall screens keep the token and are not confined to the allowlist; everyone else is admitted by where they are on the network. Still gated on the site's enable flag, so nothing opens by itself, and both allowlists ship empty - token-only until a site enters its ranges. - shopdb.api gains ip_in_cidrs and request_ip_allowlisted (contract 0.35.0). On the surface because both plugins need the identical gate, and a CIDR parser copied into two of them is where a fail-open creeps in later. They fail closed on an empty setting, an unparseable caller address and a malformed entry, and they read remote_addr rather than raw X-Forwarded-For - so the IIS rewrite rule is what makes them meaningful, which the module says at length. - Separate settings per kiosk, usb_kiosk_allowed_cidrs and printedparts_kiosk_allowed_cidrs. One must not grant the other, for the same reason the tokens are separate. - Both settings pages get a picker listing the network plugin's subnets with their VLANs. It stores resolved CIDR strings, not subnet ids: the setting has to work at a site that does not run that plugin and must not break when an inventory row is deleted. Entries matching no subnet are shown rather than hidden, since a stale one left by an inventory edit looks exactly like a hand-typed range. - USBKiosk says it was not admitted instead of pretending to work, which PartsKiosk already did. - The USB settings page keyed text-vs-boolean off the token alone. Adding a second text setting to the boolean path would have saved it as the word 'true' - the bug that replaced the kiosk token and 404'd every provisioned screen. Both load and save now key off a list, with a spec on it. The trade, stated plainly in the admin UI: anyone on an allowed network can check a drive in or out against a badge number they guess, and the record carries that person's name. Badges are attribution, not authorization. Keeping the ranges narrow is what bounds it; login is the alternative and was ruled out because these users do not sign in.
This commit is contained in:
129
frontend/src/components/NetworkAllowlistPicker.vue
Normal file
129
frontend/src/components/NetworkAllowlistPicker.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="allowlist-picker">
|
||||
<div v-if="loading" class="picker-note">Loading networks...</div>
|
||||
|
||||
<div v-else-if="subnets.length" class="subnet-list">
|
||||
<label v-for="subnet in subnets" :key="subnet.subnetid" class="subnet-row">
|
||||
<input type="checkbox" :value="subnet.cidr"
|
||||
:checked="isSelected(subnet.cidr)"
|
||||
@change="toggle(subnet.cidr)" />
|
||||
<span class="subnet-cidr">{{ subnet.cidr }}</span>
|
||||
<span class="subnet-name">{{ subnet.name }}</span>
|
||||
<span v-if="subnet.vlannumber" class="subnet-vlan">
|
||||
VLAN {{ subnet.vlannumber }}<template v-if="subnet.vlanname"> - {{ subnet.vlanname }}</template>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-else class="picker-note">
|
||||
{{ unavailableMessage }}
|
||||
</p>
|
||||
|
||||
<label class="manual-label">Allowed networks (CIDR, comma separated)</label>
|
||||
<input :value="modelValue" type="text" class="form-control"
|
||||
placeholder="empty = device token only"
|
||||
@input="$emit('update:modelValue', $event.target.value)" />
|
||||
|
||||
<p v-if="extraEntries.length" class="picker-note">
|
||||
Not from the network inventory: {{ extraEntries.join(', ') }}
|
||||
</p>
|
||||
<p v-if="invalidEntries.length" class="picker-warn">
|
||||
Not valid CIDR and will never match: {{ invalidEntries.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Picks networks for a kiosk allowlist from the network plugin's subnet
|
||||
// inventory, and stays usable without it.
|
||||
//
|
||||
// Stores resolved CIDR STRINGS, not subnet ids. The setting has to keep working
|
||||
// at a site that does not run the network plugin, and must not break when an
|
||||
// inventory row is edited or deleted. The trade is that a CIDR edited in the
|
||||
// inventory leaves a stale copy here - which is why entries that match no
|
||||
// subnet are listed back to the administrator rather than hidden.
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { fetchAllPages } from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const subnets = ref([])
|
||||
const loading = ref(true)
|
||||
const unavailableMessage = ref('No subnets found. Enter networks by hand below.')
|
||||
|
||||
function entries (raw) {
|
||||
return String(raw || '').split(',').map(part => part.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
const selected = computed(() => entries(props.modelValue))
|
||||
|
||||
function isSelected (cidr) {
|
||||
return selected.value.includes(cidr)
|
||||
}
|
||||
|
||||
function toggle (cidr) {
|
||||
const current = selected.value
|
||||
const next = current.includes(cidr)
|
||||
? current.filter(entry => entry !== cidr)
|
||||
: [...current, cidr]
|
||||
emit('update:modelValue', next.join(', '))
|
||||
}
|
||||
|
||||
// Entries the administrator typed that no subnet record matches. Not an error -
|
||||
// a range may simply not be inventoried - but worth showing, because a stale
|
||||
// entry left behind by an inventory edit looks exactly like this.
|
||||
const extraEntries = computed(() => {
|
||||
const known = new Set(subnets.value.map(subnet => subnet.cidr))
|
||||
return selected.value.filter(entry => !known.has(entry))
|
||||
})
|
||||
|
||||
// Shape check only, mirroring the server: anything the server cannot parse is
|
||||
// skipped there, so it silently admits nobody. Better to say so here.
|
||||
const CIDR_SHAPE = /^\d{1,3}(\.\d{1,3}){3}\/\d{1,2}$/
|
||||
const invalidEntries = computed(
|
||||
() => selected.value.filter(entry => !CIDR_SHAPE.test(entry) && !entry.includes(':')))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
subnets.value = await fetchAllPages('/network/subnets')
|
||||
} catch (error) {
|
||||
// The network plugin may be disabled at this site, or the signed-in user
|
||||
// may not hold network.view. Neither stops an administrator configuring
|
||||
// the allowlist by hand, so degrade instead of failing.
|
||||
subnets.value = []
|
||||
unavailableMessage.value =
|
||||
'Network inventory unavailable. Enter networks by hand below.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subnet-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.subnet-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
.subnet-cidr { font-family: monospace; min-width: 12rem; }
|
||||
.subnet-name { font-weight: 600; }
|
||||
.subnet-vlan { color: var(--text-light); font-size: 0.85rem; }
|
||||
.manual-label { display: block; font-weight: 600; margin-top: 0.5rem; }
|
||||
.picker-note { color: var(--text-light); font-size: 0.85rem; margin: 0.25rem 0; }
|
||||
.picker-warn { color: var(--warning-dark); font-size: 0.85rem; margin: 0.25rem 0; }
|
||||
</style>
|
||||
@@ -518,16 +518,15 @@ def adjust_item(item_id: int):
|
||||
|
||||
import hmac
|
||||
|
||||
from shopdb.api import request_ip_allowlisted
|
||||
|
||||
# Largest single take the kiosk records. A shop-floor grab is single digits; 25
|
||||
# leaves real use untouched and denies draining a bin in one request.
|
||||
KIOSK_MAX_TAKE_QUANTITY = 25
|
||||
|
||||
|
||||
def _kiosk_open():
|
||||
"""True when this site has the kiosk on AND the caller carries its token."""
|
||||
enabled = Setting.get('printedparts_kiosk_enabled', default=False)
|
||||
if not (enabled is True or str(enabled).strip().lower() in ('true', '1', 'yes', 'on')):
|
||||
return False
|
||||
def _kiosk_token_presented():
|
||||
"""True when the caller carries this kiosk's device token."""
|
||||
expected = (Setting.get('printedparts_kiosk_token') or '').strip()
|
||||
presented = (request.headers.get('X-Kiosk-Token') or '').strip()
|
||||
if not expected or not presented:
|
||||
@@ -537,6 +536,27 @@ def _kiosk_open():
|
||||
return hmac.compare_digest(presented, expected)
|
||||
|
||||
|
||||
def _kiosk_open():
|
||||
"""True when this site has the kiosk on AND the caller is admitted.
|
||||
|
||||
Two ways in, either sufficient:
|
||||
1. the per-device token, for the unattended screen by the printers, and
|
||||
2. a source address inside printedparts_kiosk_allowed_cidrs, for people
|
||||
who open the page on their own PC and never log in.
|
||||
|
||||
Its own setting, not the USB kiosk's: the two are separate screens in
|
||||
separate rooms, and one allowlist granting the other is exactly the sharing
|
||||
the separate tokens exist to prevent.
|
||||
"""
|
||||
enabled = Setting.get('printedparts_kiosk_enabled', default=False)
|
||||
if not (enabled is True or str(enabled).strip().lower() in ('true', '1', 'yes', 'on')):
|
||||
return False
|
||||
if _kiosk_token_presented():
|
||||
return True
|
||||
return request_ip_allowlisted(
|
||||
request, Setting.get('printedparts_kiosk_allowed_cidrs'))
|
||||
|
||||
|
||||
def _kiosk_shut():
|
||||
"""404, not 403 - a caller without the token does not learn the door exists."""
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404)
|
||||
|
||||
@@ -68,6 +68,18 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Networks allowed without the token</label>
|
||||
<NetworkAllowlistPicker v-model="values.printedparts_kiosk_allowed_cidrs" />
|
||||
<p class="field-hint">
|
||||
For people who open the kiosk on their own PC rather than at the
|
||||
screen. A browser on one of these networks is admitted with no token
|
||||
and no login, so keep this to the shop-floor ranges. Leave empty to
|
||||
require the token from every caller. Separate from the USB kiosk
|
||||
allowlist: one does not grant the other.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Unknown badge at the kiosk</label>
|
||||
<select v-model="values.printedparts_unknown_badge" class="form-control">
|
||||
@@ -143,6 +155,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
||||
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
|
||||
|
||||
const KEYS = [
|
||||
'printedparts_code_prefix',
|
||||
@@ -150,6 +163,7 @@ const KEYS = [
|
||||
'printedparts_default_threshold',
|
||||
'printedparts_kiosk_enabled',
|
||||
'printedparts_kiosk_token',
|
||||
'printedparts_kiosk_allowed_cidrs',
|
||||
'printedparts_unknown_badge',
|
||||
'printedparts_alert_email',
|
||||
'printedparts_alert_userids',
|
||||
@@ -163,6 +177,7 @@ const values = ref({
|
||||
printedparts_default_threshold: 5,
|
||||
printedparts_kiosk_enabled: 'false',
|
||||
printedparts_kiosk_token: '',
|
||||
printedparts_kiosk_allowed_cidrs: '',
|
||||
printedparts_unknown_badge: 'deny',
|
||||
printedparts_alert_email: '',
|
||||
printedparts_alert_userids: '',
|
||||
|
||||
@@ -172,6 +172,21 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
'Generate one, then open the kiosk once as '
|
||||
'/parts-kiosk?token=<value> on each screen',
|
||||
},
|
||||
{
|
||||
'key': 'printedparts_kiosk_allowed_cidrs',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'printedparts',
|
||||
'description': 'Networks allowed to use the kiosk WITHOUT the '
|
||||
'device token, as comma-separated CIDRs (for '
|
||||
'example 192.0.2.0/24). For people who open '
|
||||
'the kiosk on their own PC and never log in. '
|
||||
'Empty = token only. Separate from the USB '
|
||||
'kiosk allowlist on purpose. Requires the IIS '
|
||||
'X-Forwarded-For rewrite rule to be live, or the '
|
||||
'app sees the proxy address and this matches '
|
||||
'nothing.',
|
||||
},
|
||||
{
|
||||
'key': 'printedparts_unknown_badge',
|
||||
'value': 'deny',
|
||||
|
||||
@@ -39,7 +39,7 @@ from shopdb.api import (
|
||||
get_pagination_params,
|
||||
require_permission,
|
||||
)
|
||||
from shopdb.api import Setting
|
||||
from shopdb.api import Setting, request_ip_allowlisted
|
||||
|
||||
from . import selfhosted
|
||||
|
||||
@@ -743,22 +743,14 @@ def _kiosk_enabled():
|
||||
return _kiosk_setting('usb_kiosk_enabled', False)
|
||||
|
||||
|
||||
def _kiosk_open():
|
||||
"""True when the kiosk is on for this site AND the caller has its token.
|
||||
|
||||
The flag alone was the whole gate, and a flag is a decision, not a
|
||||
credential: with the kiosk on, anyone who could reach the app could move a
|
||||
device between checked-out and checked-in and have it recorded against a
|
||||
badge they guessed. A badge is a short numeric SSO, so it is attribution,
|
||||
never authorization - the same reasoning that closed the parts kiosk.
|
||||
def _kiosk_token_presented():
|
||||
"""True when the caller presents the configured device token.
|
||||
|
||||
The token is per-DEVICE. A kiosk is a browser on a screen by the cabinet, so
|
||||
a token served inside the page would be readable by anyone who could load
|
||||
it. An admin opens the screen once as /usb-kiosk?token=..., the page keeps
|
||||
it in that browser and sends it as X-Kiosk-Token.
|
||||
"""
|
||||
if not _kiosk_enabled():
|
||||
return False
|
||||
expected = (Setting.get('usb_kiosk_token') or '').strip()
|
||||
presented = (request.headers.get('X-Kiosk-Token') or '').strip()
|
||||
if not expected or not presented:
|
||||
@@ -768,6 +760,46 @@ def _kiosk_open():
|
||||
return hmac.compare_digest(presented, expected)
|
||||
|
||||
|
||||
def _kiosk_ip_allowlisted():
|
||||
"""True when the caller sits in the configured kiosk network allowlist.
|
||||
|
||||
Why this exists alongside the token: the token model assumes a handful of
|
||||
fixed screens provisioned once. Most people reach this kiosk from their own
|
||||
PC instead, and there is no login - so per-browser provisioning would mean
|
||||
provisioning every PC on the floor, and losing it on every reimage or
|
||||
cleared profile. Network position is what those callers actually have in
|
||||
common.
|
||||
|
||||
Fail-closed behaviour and the IIS rewrite-rule dependency that makes the
|
||||
caller address trustworthy are documented on request_ip_allowlisted.
|
||||
"""
|
||||
return request_ip_allowlisted(
|
||||
request, Setting.get('usb_kiosk_allowed_cidrs'))
|
||||
|
||||
|
||||
def _kiosk_open():
|
||||
"""True when the kiosk is on for this site AND the caller is admitted.
|
||||
|
||||
The flag alone was the whole gate, and a flag is a decision, not a
|
||||
credential: with the kiosk on, anyone who could reach the app could move a
|
||||
device between checked-out and checked-in and have it recorded against a
|
||||
badge they guessed. A badge is a short numeric SSO, so it is attribution,
|
||||
never authorization - the same reasoning that closed the parts kiosk.
|
||||
|
||||
Two ways in, either sufficient, mirroring what the GE-Enforce client
|
||||
endpoints already do:
|
||||
1. the per-device token, for unattended screens by the cabinet, and
|
||||
2. a source address inside usb_kiosk_allowed_cidrs, for the people who
|
||||
open this page on their own PC and never log in.
|
||||
|
||||
Both are still gated on usb_kiosk_enabled, so the door stays shut until a
|
||||
site opens it deliberately.
|
||||
"""
|
||||
if not _kiosk_enabled():
|
||||
return False
|
||||
return _kiosk_token_presented() or _kiosk_ip_allowlisted()
|
||||
|
||||
|
||||
def _kiosk_disabled_response():
|
||||
# 404, not 403: when the kiosk is off these routes should not read as
|
||||
# "there is a door here and you may not use it".
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
<p class="kiosk-hint">An administrator enables it in Settings under USB.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="refused" class="kiosk-step">
|
||||
<p class="kiosk-prompt">This screen is not set up for the kiosk.</p>
|
||||
<p class="kiosk-hint">
|
||||
The kiosk is on, but this browser was not admitted. A wall screen is
|
||||
provisioned once by opening it with its device token; a PC is admitted
|
||||
by being on an allowed network. An administrator sets both in Settings
|
||||
under USB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="error" class="kiosk-error">{{ error }}</div>
|
||||
|
||||
@@ -145,6 +155,12 @@ const error = ref('')
|
||||
const doneMessage = ref('')
|
||||
const busy = ref(false)
|
||||
const disabled = ref(false)
|
||||
// The kiosk is on, but the server refused THIS caller: no device token in this
|
||||
// browser and no allowed network. The server answers 404 rather than 403 so the
|
||||
// route does not advertise itself, which means the client cannot know until it
|
||||
// asks. Without this the page rendered as a working kiosk whose every call
|
||||
// failed - the parts kiosk already says so, and this one did not.
|
||||
const refused = ref(false)
|
||||
const manualBadge = ref('')
|
||||
const entryInput = ref(null)
|
||||
let resetTimer = null
|
||||
@@ -214,7 +230,11 @@ async function startSession (value) {
|
||||
step.value = 'pick'
|
||||
focusFilter()
|
||||
} catch (sessionError) {
|
||||
error.value = messageFrom(sessionError, 'Could not look that badge up')
|
||||
if (sessionError.response?.status === 404) {
|
||||
refused.value = true
|
||||
} else {
|
||||
error.value = messageFrom(sessionError, 'Could not look that badge up')
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
focusEntry()
|
||||
|
||||
@@ -55,6 +55,23 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Networks allowed without the token</label>
|
||||
<NetworkAllowlistPicker v-model="values.usb_kiosk_allowed_cidrs" />
|
||||
<p class="field-hint">
|
||||
For people who open the kiosk on their own PC. A browser on one of
|
||||
these networks is admitted with no token and no login, so keep this to
|
||||
the shop-floor ranges rather than everything. Leave empty to require
|
||||
the token from every caller. Wall screens keep using the token either
|
||||
way.
|
||||
</p>
|
||||
<p class="field-hint token-warn">
|
||||
Anyone on an allowed network can check a drive in or out against a
|
||||
badge number they guess, and the record carries that person's name.
|
||||
Badges are attribution, not authorization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
|
||||
@@ -82,12 +99,19 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
|
||||
|
||||
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others',
|
||||
'usb_kiosk_token']
|
||||
'usb_kiosk_token', 'usb_kiosk_allowed_cidrs']
|
||||
|
||||
// Keys whose value is text. Everything else is a switch stored as true/false.
|
||||
// Saving a text setting through the boolean path is what replaced the kiosk
|
||||
// token with the word 'true' and 404'd every provisioned screen.
|
||||
const TEXTKEYS = ['usb_kiosk_token', 'usb_kiosk_allowed_cidrs']
|
||||
|
||||
const values = ref({
|
||||
usb_kiosk_enabled: false,
|
||||
usb_kiosk_allowed_cidrs: '',
|
||||
usb_kiosk_allow_return_for_others: true,
|
||||
usb_kiosk_token: '',
|
||||
})
|
||||
@@ -115,9 +139,9 @@ async function load () {
|
||||
const response = await settingsApi.list({ category: 'usb' })
|
||||
for (const row of (response.data.data || [])) {
|
||||
if (!KEYS.includes(row.key)) continue
|
||||
// The token is text (the server sends a mask once set); only the two
|
||||
// switches are booleans.
|
||||
values.value[row.key] = row.key === 'usb_kiosk_token'
|
||||
// Text settings stay text (the server sends a mask for the token once it
|
||||
// is set); only the switches are booleans.
|
||||
values.value[row.key] = TEXTKEYS.includes(row.key)
|
||||
? (row.value || '')
|
||||
: asBool(row.value)
|
||||
}
|
||||
@@ -132,10 +156,10 @@ async function save () {
|
||||
error.value = ''
|
||||
try {
|
||||
for (const key of KEYS) {
|
||||
// Saving the token as true/false replaced the real token with the word
|
||||
// 'true', and every provisioned kiosk then got 404. The server keeps the
|
||||
// stored token when it receives the mask back unchanged.
|
||||
const value = key === 'usb_kiosk_token'
|
||||
// Saving a text setting through the boolean path replaced the real token
|
||||
// with the word 'true', and every provisioned kiosk then got 404. The
|
||||
// server keeps the stored token when it receives the mask back unchanged.
|
||||
const value = TEXTKEYS.includes(key)
|
||||
? String(values.value[key] ?? '')
|
||||
: (values.value[key] ? 'true' : 'false')
|
||||
await settingsApi.update(key, value)
|
||||
|
||||
@@ -49,9 +49,23 @@ describe('USBSettings save', () => {
|
||||
usb_kiosk_enabled: 'true',
|
||||
usb_kiosk_allow_return_for_others: 'false',
|
||||
usb_kiosk_token: MASK,
|
||||
usb_kiosk_allowed_cidrs: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('saves the network allowlist as text, not as true/false', async () => {
|
||||
// Same trap the token fell into: a text setting sent through the boolean
|
||||
// path becomes the word 'true', and the gate then admits nobody while the
|
||||
// settings page still shows something plausible.
|
||||
const wrapper = await render([
|
||||
{ key: 'usb_kiosk_enabled', value: 'true' },
|
||||
{ key: 'usb_kiosk_allowed_cidrs', value: '192.0.2.0/24, 198.51.100.0/24' },
|
||||
])
|
||||
await wrapper.find('button.btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
expect(saved().usb_kiosk_allowed_cidrs).toBe('192.0.2.0/24, 198.51.100.0/24')
|
||||
})
|
||||
|
||||
it('saves a generated token as the token itself', async () => {
|
||||
const wrapper = await render([
|
||||
{ key: 'usb_kiosk_enabled', value: 'true' },
|
||||
|
||||
@@ -150,6 +150,22 @@ class USBPlugin(BasePlugin):
|
||||
'kiosk once as /usb-kiosk?token=<value> on each '
|
||||
'screen',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_allowed_cidrs',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'usb',
|
||||
'description': 'Networks allowed to use the kiosk WITHOUT the '
|
||||
'device token, as comma-separated CIDRs (for '
|
||||
'example 192.0.2.0/24). For people who open '
|
||||
'the kiosk on their own PC and never log in - '
|
||||
'provisioning a token into every browser is not '
|
||||
'workable. Empty = token only. Wall screens keep '
|
||||
'using the token either way. Requires the IIS '
|
||||
'X-Forwarded-For rewrite rule to be live, or the '
|
||||
'app sees the proxy address and this matches '
|
||||
'nothing.',
|
||||
},
|
||||
{
|
||||
'key': 'usb_kiosk_allow_return_for_others',
|
||||
'value': 'true',
|
||||
|
||||
@@ -139,7 +139,17 @@ from .plugins import plugin_manager
|
||||
# bump can catch, and it is the kind of thing that gets copied. Exposing the
|
||||
# model closes the gap instead of leaving the workaround as the worked example.
|
||||
# Additive name on the surface, minor bump.
|
||||
__contract_version__ = '0.34.0'
|
||||
# 0.35.0 adds ip_in_cidrs and request_ip_allowlisted to shopdb.api: network
|
||||
# position as an admission check for routes that run without a login. The two
|
||||
# kiosks needed the identical gate - a device token suits an unattended screen
|
||||
# provisioned once, and suits nothing about somebody opening the same page on
|
||||
# their own PC - and a CIDR parser copied into two plugins is exactly where a
|
||||
# fail-open creeps in later. Both fail closed on an empty setting, an
|
||||
# unparseable caller address and a malformed entry. Caller trustworthiness is a
|
||||
# deployment property: they read remote_addr, never raw X-Forwarded-For, so the
|
||||
# IIS rewrite rule is what makes them meaningful. Additive names on the surface,
|
||||
# minor bump.
|
||||
__contract_version__ = '0.35.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
|
||||
@@ -187,6 +187,11 @@ from shopdb.utils.uploads import send_upload, harden_upload_response
|
||||
# nothing. See shopdb/utils/clientip.py.
|
||||
from shopdb.utils.clientip import client_ip, strip_port
|
||||
|
||||
# Network-position allowlists for the unauthenticated kiosk routes. On the
|
||||
# contract surface because two plugins need the identical gate and a
|
||||
# copy-pasted CIDR parser is exactly where a fail-open creeps in.
|
||||
from shopdb.utils.ipallowlist import ip_in_cidrs, request_ip_allowlisted
|
||||
|
||||
# Strict date parsing. On the contract surface because every plugin that stores
|
||||
# a date needs it and the two that rolled their own both swallowed the error.
|
||||
from shopdb.utils.queryargs import QueryArgError, intarg, intargs
|
||||
@@ -477,6 +482,8 @@ __all__ = [
|
||||
'harden_upload_response',
|
||||
'client_ip',
|
||||
'strip_port',
|
||||
'ip_in_cidrs',
|
||||
'request_ip_allowlisted',
|
||||
# Import-mode helpers
|
||||
'apply_import_timestamps',
|
||||
'import_mode_active',
|
||||
|
||||
64
shopdb/utils/ipallowlist.py
Normal file
64
shopdb/utils/ipallowlist.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Network-position allowlists for unauthenticated kiosk routes.
|
||||
|
||||
A kiosk route writes without a login. The device token (see the kiosk views)
|
||||
covers unattended screens an administrator provisions once, but it does not fit
|
||||
the other caller: somebody opening the kiosk page on their own PC, who never
|
||||
logs in and whose browser nobody provisioned. Per-browser provisioning would
|
||||
mean touching every PC on the floor and losing it again on the next reimage.
|
||||
What those callers have in common is where they are on the network.
|
||||
|
||||
Fails closed, in every direction that matters:
|
||||
- an empty or absent setting matches nothing (the feature is off),
|
||||
- an unparseable caller address matches nothing,
|
||||
- a malformed entry in the list is skipped rather than widening the match.
|
||||
|
||||
SPOOF RESISTANCE IS A DEPLOYMENT PROPERTY, NOT A PROPERTY OF THIS CODE.
|
||||
The caller address comes from client_ip(), which reads remote_addr and never
|
||||
the raw X-Forwarded-For header - reading that header directly would let any
|
||||
caller send 'X-Forwarded-For: <allowlisted-ip>' and walk straight through.
|
||||
remote_addr is only the real client because of what sits in front of the app:
|
||||
behind IIS the URL-Rewrite rule overwrites X-Forwarded-For with the real TCP
|
||||
peer and ProxyFix resolves it into remote_addr (TRUSTED_PROXY_COUNT). Remove
|
||||
that rule and IIS forwards whatever the caller sent; waitress trusts it because
|
||||
it arrives from 127.0.0.1, remote_addr becomes attacker-controlled, and every
|
||||
allowlist built on this becomes bypassable from anywhere on the network. It
|
||||
does NOT fail closed in that case. The rewrite rule is the control. The
|
||||
GE-Enforce client allowlist carries the same dependency.
|
||||
|
||||
Callers store plain CIDR strings rather than references to subnet records: a
|
||||
site that does not run the network plugin still has to be able to configure
|
||||
this, and a stored allowlist must not break when an inventory row is deleted.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
|
||||
from shopdb.utils.clientip import client_ip
|
||||
|
||||
|
||||
def ip_in_cidrs(address, rawcidrs):
|
||||
"""True when `address` falls inside any CIDR in the comma-separated list.
|
||||
|
||||
Pure and side-effect free so it can be tested without a request context.
|
||||
"""
|
||||
raw = (rawcidrs or '').strip()
|
||||
if not raw:
|
||||
return False
|
||||
try:
|
||||
caller = ipaddress.ip_address((address or '').strip())
|
||||
except ValueError:
|
||||
return False
|
||||
for part in raw.split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
if caller in ipaddress.ip_network(part, strict=False):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def request_ip_allowlisted(request_obj, rawcidrs):
|
||||
"""True when the request's client address is inside the allowlist."""
|
||||
return ip_in_cidrs(client_ip(request_obj), rawcidrs)
|
||||
138
tests/test_plugins/test_kiosk_network_allowlist.py
Normal file
138
tests/test_plugins/test_kiosk_network_allowlist.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Network-position admission for the two unauthenticated kiosks.
|
||||
|
||||
The device token covers unattended screens an administrator provisions once. It
|
||||
does not cover the other caller: somebody opening the kiosk page on their own
|
||||
PC, who never logs in and whose browser nobody provisioned. Per-browser
|
||||
provisioning would mean touching every PC on the floor and losing it on the next
|
||||
reimage, so those callers are admitted by where they are on the network instead.
|
||||
|
||||
What these tests hold down:
|
||||
- the allowlist actually admits a caller with no token (the feature),
|
||||
- it stays shut when unset, when the caller is outside it, and when the kiosk
|
||||
itself is off (the flag still wins),
|
||||
- a malformed entry is skipped rather than widening the match, and
|
||||
- the two kiosks do not share an allowlist.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
from shopdb.api import ip_in_cidrs
|
||||
|
||||
|
||||
PLANT = '10.134.48.17'
|
||||
PLANT_CIDR = '10.134.48.0/23'
|
||||
ELSEWHERE = '10.9.100.5'
|
||||
|
||||
|
||||
# -- the pure matcher ---------------------------------------------------------
|
||||
# Separated from the request so the fail-closed edges can be stated plainly.
|
||||
|
||||
@pytest.mark.parametrize('address, cidrs, expected', [
|
||||
(PLANT, PLANT_CIDR, True),
|
||||
(PLANT, ' 10.48.249.0/26 , 10.134.48.0/23 ', True),
|
||||
(ELSEWHERE, PLANT_CIDR, False),
|
||||
# Empty or absent setting means the feature is off, not "allow everyone".
|
||||
(PLANT, '', False),
|
||||
(PLANT, None, False),
|
||||
# A malformed entry must be skipped, never treated as a wildcard.
|
||||
(PLANT, 'not-a-cidr', False),
|
||||
(PLANT, 'not-a-cidr, 10.134.48.0/23', True),
|
||||
# An unparseable caller address matches nothing.
|
||||
('', PLANT_CIDR, False),
|
||||
('unknown', PLANT_CIDR, False),
|
||||
])
|
||||
def test_ip_in_cidrs(address, cidrs, expected):
|
||||
assert ip_in_cidrs(address, cidrs) is expected
|
||||
|
||||
|
||||
# -- USB kiosk ----------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def usbkiosk(db):
|
||||
"""Self-hosted USB, kiosk ON, a token set, one employee and one drive."""
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
from plugins.usb.models import USBDevice
|
||||
|
||||
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
|
||||
valuetype='string', category='site'))
|
||||
db.session.add(Setting(key='usb_kiosk_enabled', value='true',
|
||||
valuetype='boolean', category='usb'))
|
||||
db.session.add(Setting(key='usb_kiosk_token', value='a-real-token',
|
||||
valuetype='string', category='usb'))
|
||||
db.session.add(DirectoryEmployee(sso=270015376, firstname='Dana',
|
||||
lastname='Reyes'))
|
||||
db.session.add(USBDevice(serialnumber='USB-1', label='Blue Kingston',
|
||||
ischeckedout=False))
|
||||
db.session.commit()
|
||||
return db
|
||||
|
||||
|
||||
def _allow(db, key, value):
|
||||
row = Setting.query.filter_by(key=key).first()
|
||||
if row is None:
|
||||
db.session.add(Setting(key=key, value=value, valuetype='string',
|
||||
category='usb'))
|
||||
else:
|
||||
row.value = value
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _session(client, address):
|
||||
"""A kiosk call carrying NO token, from `address`."""
|
||||
return client.post('/api/usb/kiosk/session', json={'badge': '270015376'},
|
||||
environ_base={'REMOTE_ADDR': address})
|
||||
|
||||
|
||||
def test_usb_kiosk_refuses_tokenless_caller_when_no_allowlist(usbkiosk, client):
|
||||
"""The state before this feature: no token, no way in."""
|
||||
assert _session(client, PLANT).status_code == 404
|
||||
|
||||
|
||||
def test_usb_kiosk_admits_tokenless_caller_on_allowed_network(usbkiosk, client):
|
||||
"""THE FEATURE. Fails without the allowlist branch in _kiosk_open."""
|
||||
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, PLANT).status_code == 200
|
||||
|
||||
|
||||
def test_usb_kiosk_refuses_caller_outside_the_allowlist(usbkiosk, client):
|
||||
"""The PXE LAN is also 10.x, which is why the range has to be narrow."""
|
||||
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, ELSEWHERE).status_code == 404
|
||||
|
||||
|
||||
def test_usb_kiosk_allowlist_does_not_override_the_off_switch(usbkiosk, client):
|
||||
"""Turning the kiosk off closes it to everyone, allowlisted or not.
|
||||
|
||||
The flag is the site's decision to have this door at all; the allowlist only
|
||||
decides who walks through an open one.
|
||||
"""
|
||||
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
Setting.query.filter_by(key='usb_kiosk_enabled').first().value = 'false'
|
||||
usbkiosk.session.commit()
|
||||
assert _session(client, PLANT).status_code == 404
|
||||
|
||||
|
||||
def test_usb_kiosk_token_still_works_from_any_network(usbkiosk, client):
|
||||
"""Wall screens keep working, and are not confined to the allowlist."""
|
||||
response = client.post('/api/usb/kiosk/session',
|
||||
json={'badge': '270015376'},
|
||||
headers={'X-Kiosk-Token': 'a-real-token'},
|
||||
environ_base={'REMOTE_ADDR': ELSEWHERE})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_usb_allowlist_does_not_open_the_parts_kiosk(usbkiosk, client):
|
||||
"""Separate screens, separate rooms, separate settings.
|
||||
|
||||
The tokens are deliberately separate so provisioning one does not grant the
|
||||
other. An allowlist shared between them would hand back exactly that.
|
||||
"""
|
||||
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
Setting.query.filter_by(key='usb_kiosk_enabled').first().value = 'true'
|
||||
usbkiosk.session.add(Setting(key='printedparts_kiosk_enabled', value='true',
|
||||
valuetype='boolean', category='printedparts'))
|
||||
usbkiosk.session.commit()
|
||||
response = client.get('/api/printedparts/kiosk/lookup?code=ANY',
|
||||
environ_base={'REMOTE_ADDR': PLANT})
|
||||
assert response.status_code == 404
|
||||
Reference in New Issue
Block a user