Zum Inhalt springen

AI blog · DotApp PHP Framework 2.0

How DotApp PHP Framework protects the browser-to-PHP channel

Shop pages that save a contact form, toggle a row, or call a named PHP function do not send a naked POST. The browser talks to PHP on an encrypted, session-bound channel: generated client script, per-form binding, integrity check, then your handler. This article explains the mechanism in words. Copy-paste forms live in How to create secure forms in DotApp PHP Framework. Choosing fo-rm versus load() versus Bridge is Secure backend-frontend communication in DotApp PHP Framework.

Common mistakes

Wrong Right
Treat /assets/dotapp/dotapp.js as a file you copy into the Shop assets folder. Load the framework URL on every page that posts a form, calls load(), or uses Bridge. The route generates the script per client.
Ship a page with no Referer and wonder why the script URL 404s. The generated script route requires a usable Referer. That is an operational fact of the channel, not a feature you turn off.
Put a lone CSRF hidden field on a plain HTML form and call the Shop contact “secure”. Use fo-rm, {{ formName(saveContact) }}, the generated script, crcCheck(), then the matching form() handler.
Reuse one encrypted identifier as both a user id and a product id. Give every identifier field its own extra key (Shop.user.id versus Shop.product.id). Still call Auth::can().
Skip crcCheck() because the browser already “looks” encrypted. PHP is the authority. No integrity check, no trusted body.

What the channel is

The browser-to-PHP channel is the path every Shop UI mutation should take: a contact save, a stock toggle, a Bridge ping. Forms and $dotapp().load() (and Bridge) travel on the same encrypted, session-bound transport. A fo-rm does not make a click “more secure” than load(). Both ride the channel. Both require crcCheck() in PHP. How to pick the surface: Secure backend-frontend communication. Client boot and selectors: How to use $dotapp() JavaScript.

The client script is generated, not static

/assets/dotapp/dotapp.js is not a static file you vendor into app/modules/Shop/assets/. The framework generates it per client and injects per-session random key material. Those keys seal the transport for this browser session. Without that script, secure posts fail: fo-rm is never converted, CRC and CSRF fields are missing or wrong, and the PHP endpoint rejects the body.

The script route requires a usable Referer. If Referer is missing or too short, the route answers 404. Treat that as an operational fact when you test from unusual clients or strip headers. Do not load a raw copy from app/parts/js/ on production Shop pages. Always include the generated URL before Shop module scripts.


<script src="/assets/dotapp/dotapp.js"></script>
<script src="/assets/modules/Shop/js/contact.js"></script>
    

Pipeline from browser to PHP

One request, several layers. Nothing here is optional polish if you claim the Shop page is on the channel.

  1. The page loads /assets/dotapp/dotapp.js (generated; Referer required or the route 404s).
  2. The script holds key material for this session only.
  3. The renderer has already emitted encrypted hidden fields for each fo-rm. At runtime the script converts fo-rm to a real form and hijacks submit.
  4. On post, the client adds CRC and transport CSRF and sends the body as { data, crc }.
  5. The request carries the header dotapp: load.
  6. PHP runs $request->crcCheck(). Failure means you do not read fields.
  7. On a form, $request->form(['POST'], 'saveContact', ok, err) runs only if the bound handler, URL, and method match.
  8. You answer with ajaxReply. The client decodes it with parseReply.

formName binds handler, URL, and method

{{ formName(saveContact) }} must sit between <fo-rm> and </fo-rm>. The renderer emits encrypted hidden fields that bind three things under a per-form key: the PHP handler name, the action URL, and the HTTP method. Forging or swapping handlers from the HTML is not practical. A field that looks like “saveContact” in the markup is not a string you can edit into “deleteShop”.

If you omit the directive, or place it before <fo-rm> or after </fo-rm>, the renderer leaves the tag unchanged — a silent failure. The handler string in the template must equal the string you pass to form() in PHP. Tiny sketch (Shop contact). Full files: secure forms.


<fo-rm method="POST" id="contactForm" action="{{ var: $postAction }}">
  <input type="text" name="email" />
  {{ formName(saveContact) }}
  <button type="submit" id="contactBtn">Send</button>
</fo-rm>
    

Integrity: crcCheck() on the server

The client posts a payload plus a CRC. PHP crcCheck() is the public gate. If it fails, do not call form(), do not decrypt identifiers, do not write to shop_* tables. Raw fetch to the same URL will not satisfy the check. That is expected. load() adds CRC, transport CSRF, and the dotapp: load header for you — including when a hijacked fo-rm submits.


$dotapp().load("/shop/items/toggle", "POST", { id: $dotapp(el).attr("data-item") },
  function (raw) { var reply = $dotapp().parseReply(raw); },
  function (code) { /* 400 CRC, 403 CSRF, 404, 429 */ }
);
    

Encrypted identifiers are not authorization

Never put a raw primary key in the browser (value="7", data-id="7"). Encrypt with {{ enc(Shop.user.id): $id }} or Crypto::encrypt((string)$id, 'Shop.user.id'). Decrypt with the same extra key. A wrong key or a damaged token returns false — reject it.

The extra key ($key2) must be unique per identifier field. Ciphertext produced as Shop.user.id cannot be used as Shop.product.id. That stops cross-field mix-ups. It does not prove the visitor may edit that row. If one select lists many users, all under Shop.user.id, swapping one user token for another in that same field still decrypts. PHP still runs Auth::can() and an ownership query. Encryption is not authorization. Treat tokens as session-bound: do not store them in the database expecting another session to decrypt them.

Public call Role
{{ enc(Shop.user.id): $u.id }} Template: ciphertext for this field only
Crypto::encrypt($plain, 'Shop.product.id') PHP: same idea before you put an id in JSON
Crypto::decrypt($cipher, 'Shop.product.id') PHP: false means bad token — stop
Auth::can('Shop.users.edit') Rights. Always, even after a clean decrypt

A lone CSRF field is a narrow guarantee

CSRF as a web term still matters: a cross-site post should not run as the signed-in Shop operator. A lone CSRF hidden field only proves “this session could read a cookie secret”. It does not bind handler, URL, method, or fields. Next to formName plus Bridge it is a narrow guarantee — useful on the transport, not a substitute for the channel.

Mechanism Lone CSRF hidden field formName + channel
Proves the session could read a cookie secret Yes (weakly) Yes, plus transport CSRF
Binds a specific PHP handler name No Yes (encrypted)
Binds action URL and HTTP method No Yes
Per-form key No Yes
Payload integrity No Yes — crcCheck()
Session-bound client keys via generated dotapp.js No Yes

Named PHP functions from JS use the same channel. Binding, rate limits, and template on(click): How to call PHP from JavaScript with DotBridge.

Layers in words

Stack them. Do not pick one and skip the rest.

Layer What it protects What it does not
Generated dotapp.js Key material for this session; without it, secure posts fail Who may edit a Shop row
fo-rm / load() / Bridge + crcCheck() Transport: tamper, wrong handler, missing integrity The meaning of id=7
Unique $key2 on every identifier Cross-field mix-up (user ciphertext ≠ product ciphertext) Ownership — still Auth::can()
Transport CSRF A narrow “this session could submit this post” Handler, URL, method, or field binding
PHP Auth::can() / ownership Authorization The wire format — you still crcCheck() first
PHP is the authority

Overlays, disabled buttons, and confirm dialogs are UX. The handler that persists the change repeats crcCheck(), decrypt, rights, and validation. A visitor who posts without the overlay must still be refused, and the previous Shop row must stay unchanged.

FAQ

Can I copy dotapp.js into the Shop module?

No. The production URL is generated per client and injects session key material. A copied file has no keys for this session. Secure posts fail. Load /assets/dotapp/dotapp.js.

Why does the script URL 404 in my test client?

The route requires a usable Referer. If the header is missing or shorter than the framework expects, you get 404. Fix the client so the header is present. Do not treat the 404 as a missing static asset.

Is a CSRF token enough for the Shop contact form?

No. A lone CSRF field only proves this session could read a cookie secret. It does not bind saveContact, the action URL, the method, or the fields. Use formName on the channel.

Does fo-rm protect more than load()?

No. Both use CRC, transport CSRF, and the dotapp: load header. Use fo-rm when the user fills several fields and submits. Use load() for a click, toggle, delete, or pager. Details: backend-frontend communication.

If ids are encrypted, can I skip Auth::can()?

No. Unique extra keys stop a product token from decrypting as a user id. They do not decide whether this operator may change that user. Encrypt, then authorize.

Should browser UI return JSON with Response::json?

Channel replies use ajaxReply (the client calls parseReply). Ordinary JSON HTTP endpoints that are not this channel use Router plus Response::json. Do not mix the two on the same Shop action.

Is Bridge a different pipe?

Bridge is discrete named PHP functions from JS or template on(click). It still rides the encrypted session-bound channel. Walkthrough: DotBridge.

What happens if I forget the script tag?

fo-rm stays as an unknown tag, no CRC/CSRF is added, and PHP crcCheck() fails. Do not claim that page is on the DotApp channel.

See also