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:
cproudlock
2026-09-16 14:33:22 -04:00
parent 158ee0507e
commit da91a29155
13 changed files with 530 additions and 26 deletions

View 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>

View File

@@ -518,16 +518,15 @@ def adjust_item(item_id: int):
import hmac import hmac
from shopdb.api import request_ip_allowlisted
# Largest single take the kiosk records. A shop-floor grab is single digits; 25 # 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. # leaves real use untouched and denies draining a bin in one request.
KIOSK_MAX_TAKE_QUANTITY = 25 KIOSK_MAX_TAKE_QUANTITY = 25
def _kiosk_open(): def _kiosk_token_presented():
"""True when this site has the kiosk on AND the caller carries its token.""" """True when the caller carries this kiosk's device 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
expected = (Setting.get('printedparts_kiosk_token') or '').strip() expected = (Setting.get('printedparts_kiosk_token') or '').strip()
presented = (request.headers.get('X-Kiosk-Token') or '').strip() presented = (request.headers.get('X-Kiosk-Token') or '').strip()
if not expected or not presented: if not expected or not presented:
@@ -537,6 +536,27 @@ def _kiosk_open():
return hmac.compare_digest(presented, expected) 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(): def _kiosk_shut():
"""404, not 403 - a caller without the token does not learn the door exists.""" """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) return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404)

View File

@@ -68,6 +68,18 @@
</p> </p>
</div> </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"> <div class="form-group">
<label>Unknown badge at the kiosk</label> <label>Unknown badge at the kiosk</label>
<select v-model="values.printedparts_unknown_badge" class="form-control"> <select v-model="values.printedparts_unknown_badge" class="form-control">
@@ -143,6 +155,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { settingsApi, usersApi, supportteamsApi } from '@/api' import { settingsApi, usersApi, supportteamsApi } from '@/api'
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
const KEYS = [ const KEYS = [
'printedparts_code_prefix', 'printedparts_code_prefix',
@@ -150,6 +163,7 @@ const KEYS = [
'printedparts_default_threshold', 'printedparts_default_threshold',
'printedparts_kiosk_enabled', 'printedparts_kiosk_enabled',
'printedparts_kiosk_token', 'printedparts_kiosk_token',
'printedparts_kiosk_allowed_cidrs',
'printedparts_unknown_badge', 'printedparts_unknown_badge',
'printedparts_alert_email', 'printedparts_alert_email',
'printedparts_alert_userids', 'printedparts_alert_userids',
@@ -163,6 +177,7 @@ const values = ref({
printedparts_default_threshold: 5, printedparts_default_threshold: 5,
printedparts_kiosk_enabled: 'false', printedparts_kiosk_enabled: 'false',
printedparts_kiosk_token: '', printedparts_kiosk_token: '',
printedparts_kiosk_allowed_cidrs: '',
printedparts_unknown_badge: 'deny', printedparts_unknown_badge: 'deny',
printedparts_alert_email: '', printedparts_alert_email: '',
printedparts_alert_userids: '', printedparts_alert_userids: '',

View File

@@ -172,6 +172,21 @@ class PrintedpartsPlugin(BasePlugin):
'Generate one, then open the kiosk once as ' 'Generate one, then open the kiosk once as '
'/parts-kiosk?token=<value> on each screen', '/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', 'key': 'printedparts_unknown_badge',
'value': 'deny', 'value': 'deny',

View File

@@ -39,7 +39,7 @@ from shopdb.api import (
get_pagination_params, get_pagination_params,
require_permission, require_permission,
) )
from shopdb.api import Setting from shopdb.api import Setting, request_ip_allowlisted
from . import selfhosted from . import selfhosted
@@ -743,22 +743,14 @@ def _kiosk_enabled():
return _kiosk_setting('usb_kiosk_enabled', False) return _kiosk_setting('usb_kiosk_enabled', False)
def _kiosk_open(): def _kiosk_token_presented():
"""True when the kiosk is on for this site AND the caller has its token. """True when the caller presents the configured device 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.
The token is per-DEVICE. A kiosk is a browser on a screen by the cabinet, so 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 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. An admin opens the screen once as /usb-kiosk?token=..., the page keeps
it in that browser and sends it as X-Kiosk-Token. 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() expected = (Setting.get('usb_kiosk_token') or '').strip()
presented = (request.headers.get('X-Kiosk-Token') or '').strip() presented = (request.headers.get('X-Kiosk-Token') or '').strip()
if not expected or not presented: if not expected or not presented:
@@ -768,6 +760,46 @@ def _kiosk_open():
return hmac.compare_digest(presented, expected) 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(): def _kiosk_disabled_response():
# 404, not 403: when the kiosk is off these routes should not read as # 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". # "there is a door here and you may not use it".

View File

@@ -12,6 +12,16 @@
<p class="kiosk-hint">An administrator enables it in Settings under USB.</p> <p class="kiosk-hint">An administrator enables it in Settings under USB.</p>
</div> </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> <template v-else>
<div v-if="error" class="kiosk-error">{{ error }}</div> <div v-if="error" class="kiosk-error">{{ error }}</div>
@@ -145,6 +155,12 @@ const error = ref('')
const doneMessage = ref('') const doneMessage = ref('')
const busy = ref(false) const busy = ref(false)
const disabled = 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 manualBadge = ref('')
const entryInput = ref(null) const entryInput = ref(null)
let resetTimer = null let resetTimer = null
@@ -214,7 +230,11 @@ async function startSession (value) {
step.value = 'pick' step.value = 'pick'
focusFilter() focusFilter()
} catch (sessionError) { } 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 { } finally {
busy.value = false busy.value = false
focusEntry() focusEntry()

View File

@@ -55,6 +55,23 @@
</p> </p>
</div> </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"> <div class="form-group">
<label class="check-row"> <label class="check-row">
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others" <input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
@@ -82,12 +99,19 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api' import { settingsApi } from '@/api'
import { withBase } from '@/utils/basePath' import { withBase } from '@/utils/basePath'
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others', 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({ const values = ref({
usb_kiosk_enabled: false, usb_kiosk_enabled: false,
usb_kiosk_allowed_cidrs: '',
usb_kiosk_allow_return_for_others: true, usb_kiosk_allow_return_for_others: true,
usb_kiosk_token: '', usb_kiosk_token: '',
}) })
@@ -115,9 +139,9 @@ async function load () {
const response = await settingsApi.list({ category: 'usb' }) const response = await settingsApi.list({ category: 'usb' })
for (const row of (response.data.data || [])) { for (const row of (response.data.data || [])) {
if (!KEYS.includes(row.key)) continue if (!KEYS.includes(row.key)) continue
// The token is text (the server sends a mask once set); only the two // Text settings stay text (the server sends a mask for the token once it
// switches are booleans. // is set); only the switches are booleans.
values.value[row.key] = row.key === 'usb_kiosk_token' values.value[row.key] = TEXTKEYS.includes(row.key)
? (row.value || '') ? (row.value || '')
: asBool(row.value) : asBool(row.value)
} }
@@ -132,10 +156,10 @@ async function save () {
error.value = '' error.value = ''
try { try {
for (const key of KEYS) { for (const key of KEYS) {
// Saving the token as true/false replaced the real token with the word // Saving a text setting through the boolean path replaced the real token
// 'true', and every provisioned kiosk then got 404. The server keeps the // with the word 'true', and every provisioned kiosk then got 404. The
// stored token when it receives the mask back unchanged. // server keeps the stored token when it receives the mask back unchanged.
const value = key === 'usb_kiosk_token' const value = TEXTKEYS.includes(key)
? String(values.value[key] ?? '') ? String(values.value[key] ?? '')
: (values.value[key] ? 'true' : 'false') : (values.value[key] ? 'true' : 'false')
await settingsApi.update(key, value) await settingsApi.update(key, value)

View File

@@ -49,9 +49,23 @@ describe('USBSettings save', () => {
usb_kiosk_enabled: 'true', usb_kiosk_enabled: 'true',
usb_kiosk_allow_return_for_others: 'false', usb_kiosk_allow_return_for_others: 'false',
usb_kiosk_token: MASK, 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 () => { it('saves a generated token as the token itself', async () => {
const wrapper = await render([ const wrapper = await render([
{ key: 'usb_kiosk_enabled', value: 'true' }, { key: 'usb_kiosk_enabled', value: 'true' },

View File

@@ -150,6 +150,22 @@ class USBPlugin(BasePlugin):
'kiosk once as /usb-kiosk?token=<value> on each ' 'kiosk once as /usb-kiosk?token=<value> on each '
'screen', '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', 'key': 'usb_kiosk_allow_return_for_others',
'value': 'true', 'value': 'true',

View File

@@ -139,7 +139,17 @@ from .plugins import plugin_manager
# bump can catch, and it is the kind of thing that gets copied. Exposing the # 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. # model closes the gap instead of leaving the workaround as the worked example.
# Additive name on the surface, minor bump. # 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 # Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent # plugin-contract version above are distinct series with independent

View File

@@ -187,6 +187,11 @@ from shopdb.utils.uploads import send_upload, harden_upload_response
# nothing. See shopdb/utils/clientip.py. # nothing. See shopdb/utils/clientip.py.
from shopdb.utils.clientip import client_ip, strip_port 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 # 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. # a date needs it and the two that rolled their own both swallowed the error.
from shopdb.utils.queryargs import QueryArgError, intarg, intargs from shopdb.utils.queryargs import QueryArgError, intarg, intargs
@@ -477,6 +482,8 @@ __all__ = [
'harden_upload_response', 'harden_upload_response',
'client_ip', 'client_ip',
'strip_port', 'strip_port',
'ip_in_cidrs',
'request_ip_allowlisted',
# Import-mode helpers # Import-mode helpers
'apply_import_timestamps', 'apply_import_timestamps',
'import_mode_active', 'import_mode_active',

View 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)

View 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