A public "active incidents" board for TicketsCAD — and three mistakes I made building the first one

37 views
Skip to first unread message

Ronald Jones

unread,
Aug 9, 2026, 11:45:21 AM (13 days ago) Aug 9
to Open Source CAD
I've been running a public-facing active incidents page on my install for a while — the sort of thing you'd link from an agency website or put on a lobby screen. I rewrote it this week after finding that my first version was quietly handing out things it shouldn't. Posting the whole thing here in case anyone wants it, but mostly because the mistakes are ones anyone building the same page would make.

To be clear about what this is: public-incidents.php is not part of TicketsCAD. It's a page I wrote locally against the shipped api/feed.php. Nothing below is a defect in Eric's code — the bugs were mine. I'm posting them because if you've built something similar against the feed, you may have built the same three.

------------------------------------------------------------------

The three things wrong with my first version

1. The page published the API key that unlocked its own feed.

The page had no login by design. It fetched the feed client-side, which meant the key had to be in the page:

php
var FEED_URL = 'api/feed.php?format=json&key=<?= get_variable('feed_api_key') ?>';

Anyone who loaded the page and hit View Source had the key. And that key isn't scoped — it reads every open incident with full addresses, which is exactly what Settings warns you about: *"Anyone with this key can read every open incident, including PII."*

I checked mine by simply requesting the page and grepping for key=. It was right there.

2. The address masking wasn't masking anything.

There's a "Public Feed Address Detail" setting — full address, block level, city and state, or hidden. I implemented it in JavaScript, and my own code comment cheerfully admitted the problem:

> Applied client-side, in this page only — api/feed.php itself always returns the exact address.

So the browser received 412 Oak St and displayed 400 block of Oak St. The real address was in the network tab, and in the page's memory, for anyone who cared to look. Combined with (1), anyone with the key could just call the feed directly and skip the theatre entirely.

3. An unauthenticated endpoint answered arbitrary incident IDs.

I'd added a small helper so the page could show assigned units, since the feed only returns a count:

GET public-incidents.php?ajax=units&ids=1,2,3,4,5

No key, no session, no check that those IDs were even incidents the page was displaying. Hand it a range of IDs and it returns unit names and statuses.

None of these are subtle once you see them. All three come from the same root cause: I put the trust boundary in the browser.

------------------------------------------------------------------

What v2 does differently

The whole design follows from one rule: *the page holds no credential, and the server never sends what the visitor may not see.*

A separate server-side endpoint. api/public-feed.php is same-origin and takes no key. It reads the data internally, redacts it, and returns only what's publishable. The feed key never leaves the server. Importantly, api/feed.php is untouched — it's upstream code every install relies on, and this is a display policy for one page.

Redaction happens before the JSON is built. Not in the browser. There's nothing in the network tab to un-redact.

Units come from the incidents actually being published, never from IDs supplied by the caller. The ajax=units endpoint is gone.

A response cache, admin-settable. My box is Windows 11 Home, where IIS caps concurrent requests at 3 — and TicketsCAD's own SSE streams hold two of them. An uncached public page plus a few visitors starves the application. Even on a healthy host, a public page is the one thing on your install a stranger can hammer.

------------------------------------------------------------------

Masking levels, measured

I added a "Street & city" level because block-level felt too precise and city-only too vague. Verified on a real incident at every setting:

  Setting         Address shown                        Map pin
  -------------   ----------------------------------   -------
  Full address    412 Oak St, Cleveland, OH            exact
  Block level     400 block of Oak St, Cleveland, OH   ~110 m
  Street & city   Oak St, Cleveland                    ~1.1 km
  City & state    Cleveland, OH                        ~1.1 km
  Hidden          (nothing)                            no map

On the map, which is the part I got wrong twice. My first instinct was to publish coordinates only at "full" detail, on the grounds that a map pin *is* an address — publishing exact coordinates next to a masked street undoes the masking completely. That's true, but it also meant the map vanished at every privacy setting, which is when a public board is most useful.

The answer is to reduce the *precision* of the coordinates to match the text, server-side. Rounded to 3 decimal places a pin is good to about 110 m, which is the same claim "400 block of Oak St" makes. At 2 decimals it's about a kilometre. The page also caps how far you can zoom in — letting someone zoom to street level on a kilometre-accurate pin implies precision the data doesn't have — and states in plain words how approximate the pins are.

------------------------------------------------------------------

Medical calls

I wanted these visible but not detailed. They're published as presence only: no type, no address, no narrative, no pin. Just "Medical Call", the time, and the units committed.

I deliberately did *not* omit them. A board that silently drops every EMS run misrepresents how busy the agency is, and anyone comparing it to what they can hear on a scanner will notice. Showing that something is happening without saying what seems the more honest failure.

The event number is still shown on redacted cards. It's the agency's own reference, it's what a caller quotes on the phone, and it gives away nothing about location.

Which type groups count as medical is a setting — it's EMS by default, matching the seeded Incident Types, but if your groups are named differently you can list your own.

------------------------------------------------------------------

Settings

All under Settings → Integrations → External Incident Feed:

  Setting                      Default        What it does
  --------------------------   ------------   -----------------------------------------
  Public Feed Address Detail   Block level    The five levels above
  Public Feed Cache            20 s           Payload TTL; the page polls at this rate
  Publication Delay            0 min          Withhold incidents until they're this old
  Medical Type Groups          EMS            Comma-separated in_types.group values
  Medical Label                Medical Call   Public name for a redacted call

The publication delay is worth a thought even if you set it to zero. Some agencies don't want a live public board during an active response; a five or ten minute delay makes it a record rather than a live feed.

------------------------------------------------------------------

If your key has been exposed

There's a Generate button next to the Feed API Key in Settings (it's been there — I built a duplicate before noticing it, which tells you how carefully I read). Generate, then Save.

Rotating breaks every external consumer of api/feed.php until you give them the new key. That's the point. But note the thing that made v1 genuinely bad: rotating the key would have broken the public board too, because the board was a consumer. The fix for a leak taking down your public display, at exactly the moment you're dealing with a leak. In v2 the board holds no key, so rotation costs it nothing.

If you're running something like my v1, rotate anyway. The key has been in the page source for as long as the page has been reachable.

------------------------------------------------------------------

Practicalities

Nothing to install. Bootstrap and Leaflet are already vendored in TicketsCAD, cache/ already exists, there's no migration and no new dependency. Two files and a handful of settings.

Happy to share the code with anyone who wants it — say the word and I'll post it or send it over. It's written against 4.2.11.

Two caveats. It's a local page, not an upstream feature, so it won't be maintained by anyone but you, and a future release could change api/feed.php's shape underneath it. And it's had one pair of eyes on it — mine — which given the three bugs in v1 is worth weighing. If you put it in front of the public, read it first.

Ron

Ronald Jones

unread,
Aug 9, 2026, 11:58:24 AM (13 days ago) Aug 9
to Open Source CAD
Follow-up to: A public "active incidents" board for TicketsCAD — and three mistakes I made building the first one
https://groups.google.com/g/open-source-cad/c/2vV-NYtBu2Q

------------------------------------------------------------------

Quick update, since I said in the original post that this was written against 4.2.11 and that's now three releases behind.

I've upgraded that install to 4.2.14 and the board needed no changes. Posting the detail rather than just "still works", because the reason it held is the useful part if you're thinking of adopting it.

What I checked after the merge

- Every asset the page loads returns 200 — I check this explicitly now, because a page whose stylesheets all 404 still returns HTTP 200 and looks fine to a status check
- All five masking levels still redact correctly, server-side, with map pins rounded to match the text
- The feed endpoint returns valid JSON, caching works, and the page still contains no API key
- The ?ajax=units endpoint from v1 is still gone
- 182 migrations applied clean

Why it survived

api/feed.php is unchanged across 4.2.12, 4.2.13 and 4.2.14. That matters because api/public-feed.php deliberately doesn't call the feed — it mirrors the same query against the same tables. So the coupling isn't to an endpoint's output format, it's to the schema underneath, which moves a lot less.

The only file touched on both sides was settings.php, where I'd added the new fields. That auto-merged, and I verified afterwards that it diffed +40/−0 against the release tag — purely my additions, nothing of upstream's lost. Worth doing that check rather than trusting a clean merge: I got caught by exactly that on an earlier upgrade, where a file merged without conflict and I kept the wrong version of it.

The thing that would break it

If ticket, in_types or assigns change shape. api/public-feed.php reproduces api/feed.php's query — open is status = 2, soft deletes are deleted_at, the type join is in_types_id, the timestamp column is date, and there is no ticket.address column at all (the address is composed from street/city/state). Every one of those differs from the obvious guess, and PHP's db_fetch_all() swallows a bad query into an empty result — so if you copy this and get a column wrong, it looks exactly like "no open incidents" rather than like an error. That cost me a while.

If you adopt it, that's the bit to re-check after a release that touches incident storage.

Still happy to share the code with anyone who wants it.

Ron

Ronald Jones

unread,
Aug 9, 2026, 1:16:59 PM (13 days ago) Aug 9
to Open Source CAD
Posting the code, so you can see in your testbeds. 

https://gist.github.com/rjonesbsink/d92d24f3932ec3c8de62230885b9f06b

Four files: two new ones, a 40-line additive patch to settings.php, and an
install guide. No migration, no new dependency, no JavaScript changes --
Bootstrap and Leaflet are already vendored in TicketsCAD, and the new settings
ride the data-key dispatcher that's already in config.js. Don't edit config.js;
I built a duplicate of a button that already existed and would rather you
didn't repeat it.

Copying the two files is enough to have it running. Everything has a sensible
default baked in -- block-level masking, a 20-second cache -- so the settings
patch only exists so an admin can change those from Settings rather than the
database. There's SQL in the guide if you'd rather not touch settings.php at
all. The patch applies clean to a stock 4.2.14 and I checked the result parses
before posting it.

Note that gists can't hold folders, so public-feed.php is flat in there. It has
to go into api/, not the install root.

Two parts of the guide I'd point at specifically.

Step 3 is four verification commands: no API key in the page source, redaction
visible in the raw JSON rather than applied in the browser, coordinates actually
truncated, and the v1 helper endpoint gone. They take about a minute and they
are exactly the checks that would have caught the three bugs in my first
version. Worth running before the page faces the public rather than after.

And if the board comes up empty, suspect the schema before anything else.
db_fetch_all() swallows a bad query into an empty result, so a wrong column name
looks identical to "no open incidents" -- there's no error anywhere. The guide
lists the six column facts that differ from the obvious guess. That cost me an
afternoon.

One bug I found while packaging this, which matters if anyone pulled an earlier
copy from me directly. The response cache wrote to a fixed filename in the
system temp directory. That directory is shared across every site on a host, so
two TicketsCAD installs on one server used the same file -- and because the
validity check was built from the settings alone, two installs configured alike
would each accept the other's payload as valid and publish the other agency's
incidents. Fixed in what's posted; the filename and the check now both include
the install path.

It's worth saying how that one surfaced. It came through a security review of
this page clean, because I have one install and a single-install host cannot
exhibit it. It only appeared when I sat down to package the files for somebody
else's server. If you have written something local against TicketsCAD and never
tried to hand it to another operator, that exercise is worth doing on its own.

Usual caveats stand. This is a local addition, not an upstream feature -- Eric
doesn't maintain it, it won't arrive in your next update, and a future release
could change the schema underneath it. It has had one pair of eyes on it, mine,
which given the three problems in v1 is worth weighing. Read it before you put
it in front of the public.

This is not part of TicketsCAD. It's a local addition written against the shipped schema. Eric doesn't maintain it and it won't arrive in your next update — if a future release changes how incidents are stored, this is yours to fix. Read it before you put it in front of the public; it's had one pair of eyes on it, and v1 had three real bugs in it that I only found by going looking.

Written against 4.2.11. Verified unchanged on 4.2.14.


 If a user should report it not working, the first question to ask is whether the board is empty versus erroring — empty points at the schema list, and errors point at the file landing outside api/.



Ron

Ronald Jones

unread,
Aug 9, 2026, 3:56:03 PM (13 days ago) Aug 9
to Open Source CAD
Update to the public incidents board. Same gist, same link -- it's been
revised in place:

https://gist.github.com/rjonesbsink/d92d24f3932ec3c8de62230885b9f06b

If you already copied the files, one of the items below is a correctness bug
rather than a new feature, so it's worth re-pulling.

THE BUG: incident times were wrong for anyone outside your timezone

The ticket date column is a bare datetime in the server's local time, and I
was publishing it untouched. A browser parses "2026-08-09T14:14:09" as the
VIEWER's local time, not the server's. So:

  A viewer east of you got an invented age. A call placed seconds ago
  displayed as "5 hr ago" from London.

  A viewer west of you got the opposite, and this is the bad one. The
  elapsed-time calculation clamps at zero, so the skew was hidden -- from
  Los Angeles, anything under about three hours old showed "just now".

Only someone in your own timezone saw correct times. On a public board that
second case is the one that matters: a three-hour-old incident reading as
"just now" is exactly what the staleness indicator exists to prevent, and it
fails quietly.

Fixed by publishing ISO-8601 with an offset. Worth noting the shipped
api/feed.php already handles this correctly in its Atom and RSS output --
gmdate plus strtotime -- and it's only the JSON branch that passes the raw
column through. So the pattern to copy was already in the codebase and I
didn't look. If you've built anything that consumes the JSON feed's opened
or updated fields, check how you're parsing them.

A "never publish" list

The medical setting redacts: an EMS run still appears, stripped of detail.
That's deliberate, and I'd keep it. But some call types shouldn't appear at
all -- domestic violence, mental health and suicide calls, sexual assault,
anything involving a juvenile. For those, presence itself is the disclosure.
"Something is happening right now" plus a scanner, or a neighbour's window,
is enough. Redaction doesn't help; absence is the only honest answer.

Two new settings take a comma-separated list, by type group and by
individual type. Suppression happens in the SQL query, so an excluded
incident never enters the result set at all.

Type-level matters separately from group-level: SUICIDE ships inside the EMS
group on a stock install, so excluding it by group would suppress every EMS
call on your board.

Matching is case-insensitive on purpose. An exclusion that misses because
someone typed "Mental Health" where the type reads "MENTAL HEALTH" fails
open -- it publishes the thing you told it to hide.

Nothing about the exclusions appears in the output, not even a count.
"3 incidents withheld" would confirm to an observer that something is being
withheld right now, which is most of what you were trying to avoid.

Rate limiting, and one setting you can get wrong

The endpoint is the one thing on your install a stranger can call without a
credential. There's now a per-address limit, default 300/minute, 0 to
disable. It fails open: if the counter can't be written, requests are
allowed, because a public safety board going dark over a read-only temp
directory is a worse failure than an unthrottled one.

The default is deliberately loose -- roughly 100 simultaneous viewers from
one address. Mobile carriers put very large numbers of subscribers behind a
single address and an office building is one address, so a limit tuned to
"reasonable per person" would throttle real visitors during exactly the
incident that made them look.

The setting to get right: if TicketsCAD sits behind nginx, IIS ARR,
Cloudflare or similar, turn ON "Behind a reverse proxy". Otherwise every
visitor arrives as the proxy's address, they all share one bucket, and the
board starts refusing everyone at once. If you are NOT behind a proxy, leave
it off -- X-Forwarded-For is supplied by the caller and trivially forged, so
honouring it on a directly-exposed server lets anyone reset their own limit,
which is worse than no limit because it looks like protection.

Crawlers

The page had a noindex meta tag. The JSON endpoint didn't, and a meta tag
only exists inside an HTML document -- it can't protect a JSON response. The
endpoint is directly fetchable and returns the same addresses. Anything that
put that URL in front of a crawler would have got the payload indexed, and a
search cache outlives both your publication delay and any later tightening
of your masking level. Now sends X-Robots-Tag.

Bandwidth

The endpoint sets an ETag and answers 304 when nothing has changed. The tag
excludes the generation timestamp, so it only changes when incidents change
-- on a quiet board nearly every poll becomes an empty 304.

While wiring that up I found the page was fetching with cache: 'no-store',
which forbids keeping a copy, so the browser had no ETag to send and could
never receive a 304. My own page was bypassing the feature I'd just added.
It's 'no-cache' now, which revalidates instead.

Accessibility

If you're a government body this is an obligation rather than a nicety, so:
one live region that speaks only when the content actually changes (a board
announcing a clock time to a screen reader every 20 seconds is noise), each
incident is a heading so heading-navigation walks the board, the grid has
list semantics so a reader is told the count first, and the map is hidden
from assistive tech with Leaflet's keyboard handling off -- everything the
map shows is already in the cards as text, and otherwise Leaflet plants a
tab stop inside a subtree screen readers can't see.

Two things I have NOT done and won't claim: nobody has run this against an
actual screen reader, and colour contrast is unmeasured, since the palette
comes from whatever Bootstrap theme your install uses. If you have a review
process, the page is small enough to put through it.

Same caveats as before. Local addition, not upstream, not maintained by
anyone but you, one pair of eyes on it. The install guide's verification
section is now six commands -- run them before the page faces the public.

Ron

Ronald Jones

unread,
Aug 9, 2026, 4:39:48 PM (13 days ago) Aug 9
to Open Source CAD
my last for now: 

Kiosk mode is in, for anyone who wants this on a lobby screen rather than a
website. Same gist, revised in place:

https://gist.github.com/rjonesbsink/d92d24f3932ec3c8de62230885b9f06b

Add ?kiosk=1 to the URL. Two optional parameters: ?per= for cards per page
(default 9) and ?rotate= for seconds per page (default 12).

  https://your-host/public-incidents.php?kiosk=1&per=9&rotate=12

It's a query parameter rather than a setting on purpose. Most agencies want
both at once -- the normal page linked from the public website, and a display
in the entrance. Two URLs, one page, one code path.

That last part is worth being explicit about: kiosk mode sets one CSS class
and flips three JavaScript constants. The fetch, the redaction, the caching,
the rate limit are all identical. There is no second path that could publish
something the normal page wouldn't.

What it changes: type scales up and the pointer is hidden, for reading across
a room. Pages cycle when there are more incidents than fit, with a "showing
10-18 of 30, page 2 of 4" line. The screen is kept awake through the Wake Lock
API where the browser supports it.

But the reason the mode exists is the failure state.

On the normal page a person sees "Not updating", shrugs, and reloads. A wall
display has nobody watching it -- that's its defining property. A small red
dot can sit there through an entire shift while the screen shows incidents
from this morning as if they were current. So in kiosk mode a failure claims
the full width in plain language and drains the colour out of the stale board
behind it. A board that is obviously broken is safer than one that is quietly
wrong. Everything else in kiosk mode is convenience; this part is the point.

One caveat: Wake Lock is the single piece that depends on your browser and
kiosk shell. Where it's unsupported or refused, the display may still sleep,
and that's an OS setting rather than something this page can force.

A BUG THIS TURNED UP, which matters whatever mode you run

To exercise the paging I seeded 30 incidents -- ten Law, ten EMS, ten Fire,
with units assigned -- and left the install on the "street and city" masking
level. The board then told me, in its own policy line at the bottom:

  "Locations shown as city and state only."

while every card on screen read "St Clair Ave, Cleveland".

The policy line had no branch for street_city, so it fell through to the
city_state wording. That level was added after the line was written and
nothing tied the two together.

I'd call this worse than a cosmetic bug. That sentence is how a member of the
public judges what the agency is disclosing, and it was understating it --
someone reads "city and state only" and reasonably assumes the street is
withheld, while the street is right there on screen. Fixed, and all five
levels are now checked against a real incident at each setting rather than
against my memory of what the code does.

If you're running any version of this board, look at the bottom line of your
own page and check it against what the cards actually show. That's a
ten-second check and it's the kind of thing that only ever gets noticed by
accident.

Worth adding a general point: I found this because I finally put realistic
data in front of it. Thirty incidents across three groups with units attached
showed me in one screen something that months of testing against one or two
incidents never did. If you've built anything local against TicketsCAD, seed
it properly once and just look at it.

Usual caveats. Local addition, not upstream, not maintained by anyone but you,
one pair of eyes on it. The install guide's verification section is six

commands -- run them before the page faces the public.

Ron

Ronald Jones

unread,
Aug 9, 2026, 5:14:06 PM (13 days ago) Aug 9
to Open Source CAD
IMG_0478.jpeg
IMG_0481.jpeg

Ronald Jones

unread,
Aug 10, 2026, 11:54:26 AM (12 days ago) Aug 10
to Open Source CAD
A batch of changes to the public incidents board, plus one correction to
something I told you all earlier that was wrong.

Same gist, revised in place throughout:
https://gist.github.com/rjonesbsink/d92d24f3932ec3c8de62230885b9f06b

I've been holding these rather than posting one message per change. The code
has been current the whole time — if you pull today you already have all of
this. Only the announcement is batched.


CORRECTION FIRST: the Generate button

In the original write-up I said:

  "There's a Generate button next to the Feed API Key in Settings — Generate,
  then Save."

That is wrong for the only people who need it, and I'm sorry for the wasted
looking if anyone went hunting.

The Generate button sits inside the orange "External feed is disabled" banner,
and that banner is hidden as soon as a key is configured. So it's an onboarding
control — "you have no key, make one" — not a rotation control. Anyone rotating
after a leak already has a key, and will find no button at all.

I only checked that the button existed, not when it renders. Same mistake as
checking a page returns HTTP 200 without checking anything on it loaded.

To actually rotate on stock TicketsCAD: paste a new key into the field and press
Save API Keys. Any long random string; 48 hex characters is what Generate would
have produced. From PowerShell:

  -join ((1..48) | ForEach-Object { '0123456789abcdef'[(Get-Random -Max 16)] })

My settings patch now adds a shuffle button next to the field that does this
properly — generates from crypto, reveals the field so you can copy the key,
and refuses to generate at all if the browser has no secure random source
(a predictable key on something that reads every open incident is worse than
no button). It's in settings.php only; assets/js/config.js stays untouched.

The underlying gap is TicketsCAD's rather than mine — stock has no way to
rotate a stored feed key — so I've raised it as openises/TicketsCAD#49 and left
the decision with Eric.


TWO THAT CHANGE WHAT A RUNNING BOARD DISCLOSES

1. The feed was publishing internal ticket ids.

The payload carried the ticket table's primary key, which the page never used.
Those ids are sequential. Two snapshots a day apart, subtract, and you have the
agency's true call volume and rate — including every incident the never-publish
list suppressed and every medical call it redacted. It partially undid the
exclusion feature: you could count what you couldn't see.

Only the event number is published now.

The transferable lesson, which is why I'm leading with it: ask what a field
discloses IN AGGREGATE OVER TIME, not just what it says about one incident. I'd
only been asking the second question.

2. Assigned units are now a setting.

Address had five masking levels, medical had redaction, incident types had
suppression — assigned units had nothing at all, and were published in full with
live status. On a public board that's a staffing picture refreshed every few
seconds: fleet size, how many are committed, how many are left, how thin cover
is right now.

  public_feed_unit_detail:
    full    Frank 11 · ENR     (default — unchanged behaviour)
    names   Frank 11           (status stripped)
    count   "3 units assigned"
    hidden  nothing, not even the count

"names" is a real middle rather than padding, because the two halves leak
different things: unit names are frequently public already — they're painted on
the vehicle — while the live status is what reveals posture. "hidden" withholds
the count too, since "4 units assigned" still answers how committed you are.

Default is "full" so upgrading never silently removes something you were already
publishing. Worth deciding deliberately rather than inheriting.


THE REST

The basemap now follows your tile_provider setting instead of hardcoding
OpenStreetMap — EXCEPT for providers that need an API key, which are refused
with a fallback to OSM. Mapbox's shipped tile URL ends "?access_token={key}".
Publishing that on a page served to anonymous visitors would print a billable
credential into public HTML. Authenticated pages can use keyed providers because
the tile proxy substitutes server-side; a public page has no such path.

Tile privacy is now disclosed rather than fixed. Whatever provider is used, the
visitor's browser contacts it directly, so that provider learns their IP and
which area — which incident — they were looking at. TicketsCAD ships a tile
proxy that would prevent this, but it requires a session by design: an
unauthenticated tile relay is an open proxy billed to the provider and
attributed to your server. I considered building a narrowed public one and
decided against it. The page now says, in its own policy line, which host it
contacts.

A 429 from the rate limiter is no longer treated as an outage. It was going
through the generic failure path, so the board announced "could not reach the
incident feed" and kiosk mode threw its full-width red banner — a false alarm
during a busy incident with lots of viewers behind one carrier address, which is
exactly when a lobby screen is being watched. It now says "waiting to update",
keeps showing what it has, and honours Retry-After.

LIMIT 200 no longer truncates silently — the board says so above the list.

The page requires JavaScript and now admits it with a noscript block, because a
blank panel reads as "no incidents" rather than "couldn't load", and on a public
safety board those are very different statements.


A CAUTION WORTH MORE THAN THE FEATURES: the blank basemap

I broke the map and want to describe the symptom, because anyone pointing a
public page at a tile provider can hit this.

The provider URLs in TicketsCAD's tile-proxy config are written for SERVER-side
fetching, where CSP doesn't apply. I reused one browser-side and got
"https://tile.openstreetmap.org/..." while the shipped CSP allows
"https://*.tile.openstreetmap.org" — a wildcard label, which the bare host does
not match. Every tile was blocked.

The map still DREW. Correctly sized, incident pins in the right places, floating
on blank background. Nothing in the console I noticed, no error state.

What separated "CSP blocked it" from "no network" was checking
img.naturalWidth === 0 on the tile elements while curl fetched the same tile
fine from the host. 0 of 12 rendered. That's the check to reach for if a map
ever looks present but empty.

The board now checks the resolved tile host against the install's own img-src
before adopting a provider — which also catches google_* and bing_*, both in the
shipped provider list, neither in the CSP allowlist.


NEW: a test you can run against your own install

  php tests/test_public_feed_redaction.php https://your-host

44 assertions over every masking level, the medical redaction, both exclusion
lists, unit detail, the truncation flag, and — the one you can't check by hand —
that tightening a setting never serves the payload cached under the looser one.
It builds its own incidents, restores every setting it touches, and cleans up
after itself including on failure. No credential needed, because the endpoint
doesn't need one.

I wrote it because every setting on this thing is a privacy control and the
failure mode is silent and in the wrong direction: a broken rule doesn't error,
it publishes more than intended and the board looks normal. That's happened
twice on my own code.

It has been checked to actually fail — deleting the masking level from the cache
key makes it report a real street address surviving a tightened setting. A test
nobody has watched fail is decoration.

On HTTPS, which is what most of you are running: pass your URL and it just
works, and certificate verification is ON. It is never silently disabled —
this project already has an advisory about TLS enforcement being bypassable
(GHSA-984v-rw78-3223), and a test that shipped with verification off would be
demonstrating that same pattern while reporting a pass.

If your install uses a self-signed or private-CA certificate, which plenty of
internal CAD boxes do, there is a --insecure flag. It has to be asked for, it
prints a banner, and the summary line marks the run TLS UNVERIFIED so a green
result can't be mistaken for a verified one. Trusting your CA on the machine
running the test is better if you can.

With no URL at all it derives one from your own `host` and `require_https`
settings rather than assuming anything about your deployment.

One gotcha, and it applies over HTTPS too: if it fails immediately after you
edit the feed file, wait a couple of seconds and re-run. It drives the
endpoint over the web server, so it executes the server's copy, and opcache
can lag a file you just saved. A failure that vanishes on a second run was
that, not a regression.



Usual caveats. Local addition, not upstream, not maintained by anyone but you,
and still only one pair of eyes on it — though there are now 44 assertions
watching the parts that matter most.

Ron
Reply all
Reply to author
Forward
0 new messages