←Jaron CabralCanvas & Clay · case study

Canvas & Clay

A digital gallery for a building with no route to the internet, and the permission model underneath it, where holding the right role is only half of the question the server asks.

95 endpoints 43 admin only 599 tests 13 tables 39,300 lines Flask · Svelte · Postgres
01

A gallery with no way out

Canvas & Clay runs on a network that has no route to the internet. A gallery installs it on one machine in the building, visitors browse it over the local network, and nothing it needs ever arrives from anywhere else.

That constraint is quick to say and awkward to build. Nearly every convenience a web application reaches for assumes the network is there: fonts from a content delivery network, an e-mail provider for password resets, an identity provider for the login screen, an update check on boot. All of it has to come out, and whatever replaces it has to keep working on a machine that nobody is going to administer.

So the application owns the whole stack. Its own accounts, its own sessions, its own password resets, its own file storage, its own backups and its own encryption keys. It is installed by a terminal wizard rather than a README, and the wizard ships with a repair mode, which is the honest part: installs fail, and the installer has to be able to fix itself without a person who knows what a container is.

FrontendSvelte, roughly 21,500 lines across the routes and components
BackendFlask, 95 endpoints over 17,900 lines of Python
DatabasePostgres on an internal compose network, no published port
StorageFiles on disk, a 500MB quota per account, disk space checked before every write
InstallA terminal wizard, setup and repair, no manual steps
KeysArgon2id derived from the environment, never from the source tree

Four of the thirteen tables have nothing to do with the web at all. storage, wall_space, rack and flat_file record where a piece physically is in the building, because a gallery that cannot tell you which wall a painting is on has not replaced the spreadsheet it was brought in to replace.

02

Three roles, and one ladder

Most role systems are a column with a string in it and a scattering of comparisons against that string, written wherever somebody needed one. This one is a ladder, declared once, and every movement along it goes through two methods.

ROLE_LADDER = ('guest', 'artist', 'admin')

def promote(self):
    idx = self.ROLE_LADDER.index(self.normalized_role)
    if idx < len(self.ROLE_LADDER) - 1:
        self.role = self.ROLE_LADDER[idx + 1]
    return self.normalized_role

An account moves one rung at a time. Promoting an administrator is a no-op rather than an error, demoting a guest is a no-op rather than an error, and an unrecognised role normalises to the bottom of the ladder before anything else happens to it. There is no code path anywhere that sets a role by assignment.

Account
guestBrowses the public gallery. Owns nothing.you are here
artistEverything a guest can do, plus their own profile and their own work.you are here
adminEverything, plus the 43 endpoints nobody else can reach.you are here
 

The ladder. Press past either end and nothing happens, which is the behaviour rather than a guard bolted on afterwards. The third button writes a legacy value straight into the column, the way an older row in an existing database already has it.

That last case is worth more than it looks. An earlier version of the schema used visitor and artist-guest. Rather than migrate every row and hope none were missed, the model normalises on read, so an old value can never reach a comparison as itself, and a row nobody remembers cannot quietly become an unknown role that no check is written for.

The other detail is the front door. Registration does not accept a role, at all:

# security fix: ignore any client-supplied role parameter
# all new users are forced to 'guest' role to prevent privilege escalation
role = 'guest'

The comment is the interesting part. It is a fix, which means at some point the endpoint read the role out of the request body, and anybody who could post JSON could have asked to be an administrator and been given it.

03

Two questions, not one

Every protected endpoint asks two things, in order. Do you stand high enough on the ladder, and does the thing you are reaching for actually belong to you? A system that asks only the first lets any artist edit any other artist. A system that asks only the second has no administrators.

What makes it readable months later is that the refusal remembers which question failed:

if not current_user.is_admin:
    if current_user.normalized_role != 'artist':
        log_rbac_denial('artist', artist_id, 'insufficient_role')
        return jsonify({'error': 'Permission denied'}), 403
    if not is_artist_owner(artist):
        log_rbac_denial('artist', artist_id, 'not_owner')
        return jsonify({'error': 'Permission denied'}), 403

Two different refusals, identical to whoever sent the request, and distinguishable in the audit log. The client is told nothing either way, because telling a caller that a record exists but is not theirs is itself a leak. But insufficient_role in the log means somebody is pushing on a door that was never going to open for them, and not_owner means somebody is in the right building and the wrong room, which is almost always a stale link rather than an attack. Collapse them into one 403 and you lose the ability to tell those apart for ever.

Signed in as
Asking to

The permission machine. The checks and their order are taken from the endpoints themselves, so the panel cannot drift from the code it is describing. Every refusal shows the reason the server would have written to the audit log, which the caller never sees.

Two combinations here are worth pressing. An administrator never reaches the ownership question at all: the first branch is if not current_user.is_admin, so the whole check falls away rather than being passed. And deleting an artwork asks about the ladder not at all, only about ownership, because an account that owns a piece has earned the right to remove it whatever rung it is standing on.

04

Ownership is three hops

No photo carries an owner. Neither does an artwork. One column in the whole schema records who something belongs to, artist.user_id, and everything else is resolved by walking towards it.

photohas an artwork_num
artworkhas an artist_id
artisthas a user_id
youare that user_id, or you are not

The chain. Three lookups per check, all of them primary key reads, and the answer is the comparison at the end.

Denormalising this would be faster and is the obvious thing to reach for. It is also how ownership quietly goes wrong: the day an artwork is reassigned to another artist, every copied owner column on every photo is stale, and a stale owner column is an authorisation bug rather than a display bug. Deriving it means there is exactly one fact, in one place, and moving a piece between artists moves everything that hangs off it in the same instant.

The chain also answers a question nobody remembered to ask. A photo whose artwork has been deleted out from under it has no first link, so it has no owner and no owner can ever be computed for it:

def is_photo_owner(photo):
    # orphaned photos can only be managed by admins
    if photo.artwork_num is None:
        return False

Every caller fails that check, including the artist who uploaded the file in the first place, which leaves administrators as the only accounts that can clear it up. That rule was never written as a rule. It falls out of a chain with nothing at the start of it, and getting behaviour like that for free is the argument for deriving ownership rather than storing it.

05

The address nobody can read

The personal data here is thin, which is the point: e-mail addresses, and nothing else worth the trouble. But it sits in a Postgres volume that a backup copies and a laptop carries out of the building, and a gallery is not going to notice a missing laptop for a week.

So the column is encrypted with AES-GCM under a fresh random nonce every time it is written. Two accounts with the same address produce two ciphertexts with nothing in common, which matters more than it sounds: with a deterministic scheme, anybody holding a dump can see which rows match each other without decrypting a single one of them.

Which creates the obvious problem. If the same address encrypts differently every time, WHERE email = ? can never find anybody again. The answer is a second column beside it, a keyed HMAC-SHA256 of the normalised address, one way, deterministic and indexed. Logging in is still one index lookup. The index reveals nothing about the address unless you already hold the key.

Register
plaintextalice@studio.local
row 1 ciphertext
row 2 ciphertext
blind index
blind index
 

Illustrative. These bytes are drawn at random in your browser to show the shape of the thing: the ciphertexts never repeat, the indexes always agree. The real values come from AES-GCM and HMAC-SHA256 on the server, and the real index is the same 64 hex characters wide.

Two decisions around it are worth naming. The keys are derived with Argon2id at 64MB, three passes and four lanes, rather than a single hash, so a leaked key file is expensive to brute force rather than instant. And the encryption key is deliberately not derived from the session secret, with a comment in the module explaining why: if the two shared a source, stealing session cookies would also decrypt the database, and one bad afternoon would become two.

06

What the audit found

The application was put through static analysis, a dependency scan and a manual penetration pass, and the result was written down rather than summarised away.

Authorisation9
Input validation9
Output encoding9
Authentication8
Sessions8
Error handling8
Infrastructure8
Cryptography7
Dependencies7
Overall8.1

Three critical findings, all of them mine, all of them fixed:

C-01The privacy page rendered stored HTML through Svelte’s @html. That text is editable from the admin console, so an administrator could store a script and every visitor to the page would run it.fixed
C-02The same sink, on the terms page. Both now pass through DOMPurify with an allowed tag list before anything reaches the DOM.fixed
C-03Postgres published its port to the host in the compose file. On a gallery network that means the database answered to any machine in the building. The service now sits on an internal network with no published port at all.fixed
M-01One encrypted column is deterministic by design, so identical values are visibly identical in a dump. It is the trade-off that makes the lookup possible, and it is written down rather than buried.documented
M-02Password reset can be timed, which leaks whether an address is registered. A dummy bcrypt hash is compared when the account does not exist, which narrows the window without closing it.documented

The two at the bottom are the ones I would want read. Neither is fixed. Both are real, both have a cost that was judged higher than the risk, and both are named in the report with the reasoning attached, because a security document that only lists the things that were easy to fix is not a security document.

Underneath all of it, the parts that were right from the start: bcrypt at twelve rounds, a fifteen minute lockout after five failed attempts, rate limits declared per endpoint, session regeneration on login, CSRF tokens, magic byte validation on every upload rather than trusting a file extension, and an audit log that every refusal writes to.

07

Five hundred and ninety nine tests

Spread over twenty five files, and the interesting thing is which ones are the largest. They are not the ones that check a page renders.

test_restore82 tests. Restoring a backup is the one operation that can destroy everything it touches.
test_reset_admin57. The account recovery path, which is a back door if it is wrong.
test_encryption54. Round trips, key rotation and the legacy fallback.
test_auth48. Registration, lockout, sessions and the forced guest role.
test_upload_utils47. Magic bytes, filenames, dimensions and quota.
test_xss_stress24. Payloads thrown at every field that survives a round trip.
test_rbac_rate_limits20. That the refusals themselves cannot be used as a probe.
test_artwork_ownership18. The three hops, and the orphan with nowhere to start.
test_security_pentest16. The findings above, kept as regressions.

Weighted towards the dangerous parts. Restore, recovery, encryption and authentication carry 241 of the 599 between them.

A gallery installs this once and then leaves it alone for a year, on a machine with no operator, behind a network with no route out and therefore no way for me to reach in and fix anything. That is the whole design constraint, and it is why the test count sits where it does. The wizard has a repair mode for the same reason.