Files
puter/src/backend/util/appIcon.ts
T
Nariman JelvehandDaniel Salazar 3d3678a555 feat: let the Contact Us form carry screenshots and recordings (#3563)
* feat: let the Contact Us form carry screenshots and recordings

The form doubles as our bug-report channel, and the bugs worth reporting
are often the ones that need a picture: a visual glitch, or something that
takes five steps to reach. Up to 5 images or videos may now ride along
with the message, delivered as attachments on the support email.

Nothing the client says about a file is believed. The endpoint takes bare
base64 and re-derives all three of the things that matter from the decoded
bytes:

- Type, sniffed from magic numbers and matched against an allow-list of
  PNG/JPEG/GIF/WebP and MP4/QuickTime/WebM. A declared MIME is never read,
  so it cannot smuggle anything past the list. SVG is excluded on purpose
  (it carries script, and support tooling renders what it is sent), as are
  the non-video brands that share MP4's `ftyp` box -- HEIC, M4A, JPEG 2000.
- The file name, reduced to a display label with the extension taken from
  the sniffed type. PNG bytes named `payload.html` arrive as
  `payload.png`; a name can never carry the CR/LF that would break out of
  a Content-Disposition header, nor the bidi overrides that make
  `report<RLO>gnp.exe` render as `report.exe.mp4`.
- Size: 10 MB per file, 15 MB per submission, counted on decoded bytes.
  Encoded length is capped before decoding, so an oversized payload costs
  a length check rather than a 10 MB allocation. The total stays well
  under the 25 MB most providers enforce, since the outgoing mail base64s
  these again.

Base64 is decoded strictly, reusing the round-tripping decoder that
already guards app icons -- `Buffer.from(s, 'base64')` silently drops
characters it does not recognise, and the round-trip is what rejects
bytes smuggled after the payload. That decoder and the image sniffer move
from appIcon.ts to a new mediaSniff.ts, which gains the video counterpart.

One request is now worth megabytes of parsing and outbound mail, so the
existing per-user rate limit gains a per-IP backstop (which the per-user
counter cannot see through freshly minted accounts), a concurrency cap,
and a Content-Length gate that refuses an impossible body before anything
decodes it.

Payloads are not stored. They ride the email; the new
`feedback.attachments` column records names, types and sizes only, so an
abusive submission stays attributable once the mail has been dealt with.

Also fixes the form posting an empty message when Send was pressed with
nothing typed, and surfaces submit failures instead of leaving the button
disabled with no explanation.

* fix: stream Contact Us attachments as multipart instead of base64 JSON

Base64 in a JSON body went through the global JSON parser before the route
ran: raw buffer, rawBody copy, decoded string, parsed strings, then decoded
Buffers plus a re-encode for the strict check. A max-size submission peaked
around 110-135 MB of heap for 15 MB of files, and the route's Content-Length
check ran after all of it.

Attachments now arrive as multipart/form-data, which the global parser skips.
Auth, rate limit, concurrency and the Content-Length 413 all run before the
body is read, and busboy enforces the count, per-file and total caps while
streaming, so only the decoded file bytes are held (~16 MB peak). On a broken
limit the reader stops and the response closes the connection.

JSON stays supported for message-only posts; a JSON `attachments` field is
rejected.

* fix: deliver Contact Us 413s instead of resetting the upload

Closing the socket on a mid-stream limit sent the 413 and then reset the
connection with the client still uploading, so the client could see a
reset instead of the error. After a limit trips, the rest of the body is
now read and discarded, bounded by the body budget; past the budget the
request is destroyed.

Adds an HTTP-level test through the full middleware stack (FormData
upload, unauthenticated refusal, 413 on declared length before the body
is sent, 413 on a chunked upload over the per-file cap), trims comments
to the AGENTS.md length rule, and drops a box-drawing test divider.

---------

Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
2026-09-25 14:19:35 -07:00

391 lines
14 KiB
TypeScript

/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
SVG_SNIFF_WINDOW,
decodeStrictBase64,
sniffImageMime,
} from './mediaSniff.js';
// Re-exported for existing importers.
export { SVG_SNIFF_WINDOW, sniffImageMime };
// Icon URLs come in a pair. `getAppIconUrl` builds the backend
// `/app-icon/<uid>/<size>` endpoint URL, which self-heals — it falls back to
// the un-resized original, decodes data URLs inline, or serves the default
// placeholder (mirroring v1's `getAppIconPath`). `getAppIconCdnUrl` builds the
// direct `puter-app-icons` subdomain URL the endpoint would redirect to.
// Clients load the direct one first and keep the endpoint as a fallback: the
// direct URL saves a redirect and survives networks that break on one, while
// the endpoint covers apps that only have the original PNG on the subdomain
// (a direct `<uid>-256.png` 404s for those).
export const DEFAULT_APP_ICON_SIZE = 256;
// The sizes AppIconService generates, and so the only ones the endpoint and
// the direct subdomain URLs can serve.
export const APP_ICON_SIZES: readonly number[] = [16, 32, 64, 128, 256, 512];
// Subdomain where AppIconService publishes generated icons.
const APP_ICONS_SUBDOMAIN = 'puter-app-icons';
// MIME types accepted on the write path for `data:` icon URLs. Anything
// outside this allowlist is rejected so a malicious caller can't stash,
// e.g., `data:text/html` or `data:application/javascript` in the icon
// column and get it echoed back by the server.
export const ICON_DATA_URL_MIME_ALLOWLIST = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
'image/svg+xml',
] as const;
interface AppIconDeps {
apiBaseUrl?: string;
}
interface TrustedIconHostConfig {
static_hosting_domain?: string;
static_hosting_domain_alt?: string;
api_base_url?: string;
}
export interface AppIconHostConfig extends TrustedIconHostConfig {
protocol?: string;
pub_port?: number;
}
const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
// `data:<type>/<subtype>[;param[=value]]…,<payload>`. The parameter list is
// matched as a group of its own so it can be checked exhaustively — the
// previous prefix-scan only looked at the bytes before the first `;` or `,`
// and never inspected the payload at all.
const DATA_URL_REGEX =
/^data:([a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*)((?:;[a-z0-9!#$&^_.+-]+(?:=[^;,]*)?)*),([\s\S]*)$/i;
/** `image/jpg` is a common misspelling of `image/jpeg`; treat them as one. */
function canonicalImageMime(mime: string): string {
return mime === 'image/jpg' ? 'image/jpeg' : mime;
}
export interface IconDataUrlVerdict {
ok: boolean;
/** Human-readable failure clause, suffixed onto "`icon` …". */
reason?: string;
/** Canonical MIME sniffed from the payload (on success). */
mime?: string;
/** Canonical, whitespace-stripped data URL to store (on success). */
normalized?: string;
}
/**
* Validate an `icon` data URL end to end — MIME, encoding, _and_ payload.
*
* The write path used to check only the MIME prefix, so everything after the
* comma was stored verbatim. `data:image/png;base64,` is a valid prefix, which
* meant up to 5 MB of arbitrary text — including `"` and `<` — could be parked
* in the icon column and later interpolated into a Dev Center template — stored
* XSS in a first-party app that holds the user's session token.
*
* Requirements, in order:
*
* - Well-formed `data:` URL with an allow-listed image MIME type
* - `;base64` and nothing else for the parameter list. Every Puter client
* produces base64 (`FileReader.readAsDataURL` always does); requiring it is
* what makes the payload checkable at all. Percent-encoded payloads — the
* only shape that can carry raw `<`/`"` — are rejected; callers with literal
* SVG markup must base64 it.
* - Strictly valid base64 (round-tripped, so smuggled non-base64 bytes fail)
* - Decoded bytes that sniff as an image whose type matches the declaration
*/
export function validateIconDataUrl(value: unknown): IconDataUrlVerdict {
if (typeof value !== 'string') {
return { ok: false, reason: 'must be a string' };
}
const match = DATA_URL_REGEX.exec(value.trim());
if (!match) {
return { ok: false, reason: 'is not a well-formed data: URL' };
}
const declaredMime = match[1].toLowerCase();
const params = match[2].toLowerCase();
const payload = match[3];
if (
!(ICON_DATA_URL_MIME_ALLOWLIST as readonly string[]).includes(
declaredMime,
)
) {
return { ok: false, reason: 'data URL must use an image MIME type' };
}
if (params !== ';base64') {
return {
ok: false,
reason: 'data URL must be base64-encoded (`;base64`) with no other parameters',
};
}
// Line-wrapped base64 is tolerated, but only whitespace may be stripped —
// any other character outside the base64 alphabet fails below.
const compact = payload.replace(/[\s]+/g, '');
const bytes = decodeStrictBase64(compact);
if (!bytes) {
return { ok: false, reason: 'data URL payload is not valid base64' };
}
const sniffed = sniffImageMime(bytes);
if (!sniffed) {
return {
ok: false,
reason: 'data URL payload is not a recognized image',
};
}
if (sniffed !== canonicalImageMime(declaredMime)) {
return {
ok: false,
reason: `data URL declares ${declaredMime} but the payload is ${sniffed}`,
};
}
return {
ok: true,
mime: sniffed,
normalized: `data:${declaredMime};base64,${compact}`,
};
}
const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/[^/?#]+(?:\/\d+)?\/?$/;
// Direct subdomain file shape written by AppIconService:
// /app-<uid>.png (original)
// /app-<uid>-<size>.png (sized variant)
// Allowed on trusted hosts only — see isAppIconEndpointUrl.
const APP_ICON_SUBDOMAIN_PATH_REGEX = /^\/app-[A-Za-z0-9_-]+(?:-\d+)?\.png$/;
/**
* Decode a bare base64 string and sniff it, or null if it isn't base64 at all
* or doesn't decode to a recognized image.
*/
function sniffRawBase64Image(
value: unknown,
): { bytes: Buffer; mime: string; compact: string } | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (trimmed.length < 16) return null;
if (!RAW_BASE64_REGEX.test(trimmed)) return null;
const bytes = decodeStrictBase64(trimmed);
if (!bytes) return null;
const mime = sniffImageMime(bytes);
if (!mime) return null;
return { bytes, mime, compact: trimmed };
}
/**
* V1-compatible raw-base64 detector. Legacy puter-js callers pass the base64
* payload without a `data:` prefix; v1 accepted it and normalized to
* `data:image/png;base64,<raw>` before storage. We mirror that here so clients
* that worked on v1 keep working.
*
* Rejects anything shorter than 16 chars, not aligned to base64 length, or that
* doesn't round-trip through Buffer — catches random strings that happen to
* match the charset. Also rejects base64 that decodes to something other than a
* recognized image: v1 wrapped any base64 as `image/png` regardless of content,
* which let non-image bytes into the icon column under an image MIME type.
*/
export function isRawBase64ImageString(value: unknown): value is string {
return sniffRawBase64Image(value) !== null;
}
/**
* Wrap raw base64 in a `data:<sniffed-mime>;base64,…` URL; pass other values
* through unchanged (the caller then rejects them — a bare string that isn't a
* valid image is not one of the accepted `icon` shapes).
*/
export function normalizeRawBase64ImageString(value: string): string {
const sniffed = sniffRawBase64Image(value);
if (!sniffed) return value;
return `data:${sniffed.mime};base64,${sniffed.compact}`;
}
/**
* Whether `value` is a reference we own — accepts two shapes:
*
* - `/app-icon/<uid>(/<size>)?` : the AppController endpoint
* - `/app-<uid>(-<size>)?.png` : the file written by AppIconService onto the
* `puter-app-icons` subdomain
*
* Relative paths must use the endpoint shape (the subdomain-file shape is only
* meaningful when paired with a trusted host). Absolute URLs accept either
* shape but only on a trusted host — without the host check an authenticated
* user could set `icon` to an attacker URL and turn `/app-icon/:uid` into a
* Puter-branded open redirector.
*/
export function isAppIconEndpointUrl(
value: string,
config: TrustedIconHostConfig,
): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
let parsed: URL;
try {
parsed = new URL(trimmed, 'http://localhost');
} catch {
return false;
}
const isEndpointPath = APP_ICON_ENDPOINT_PATH_REGEX.test(parsed.pathname);
const isSubdomainPath = APP_ICON_SUBDOMAIN_PATH_REGEX.test(parsed.pathname);
if (!isEndpointPath && !isSubdomainPath) return false;
// Relative paths (our placeholder base won't survive if the input
// was absolute). Detect absolute-vs-relative by scheme presence.
if (!/^[a-z][a-z0-9+\-.]*:/i.test(trimmed) && !trimmed.startsWith('//')) {
return isEndpointPath;
}
return isTrustedIconHost(trimmed, config);
}
/**
* Whether `url` points at a host we control for app-icon hosting.
*
* Used to gate both the legacy redirect fallback in `/app-icon/:uid` and the
* write-path validator in AppDriver — without this check an authenticated user
* can set `icon` to an arbitrary attacker URL and turn the unauthenticated
* `/app-icon/:uid` route into a Puter-branded open redirector (cached publicly
* for 15 minutes).
*
* Accepts:
*
* - `puter-app-icons.<static_hosting_domain>` (and …_alt)
* - The configured `api_base_url` host (AppIconService rewrites icon columns to
* `${api_base_url}/app-icon/<uid>`, so it must be trusted or round-tripped
* writes would fail validation).
*/
export function isTrustedIconHost(
url: string,
config: TrustedIconHostConfig,
): boolean {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
const hostname = parsed.hostname.toLowerCase();
if (!hostname) return false;
const trustedBases = [
config.static_hosting_domain,
config.static_hosting_domain_alt,
].filter((d): d is string => typeof d === 'string' && d.length > 0);
for (const base of trustedBases) {
if (hostname === `${APP_ICONS_SUBDOMAIN}.${base.toLowerCase()}`) {
return true;
}
}
if (config.api_base_url) {
try {
const apiBaseHost = new URL(
config.api_base_url,
).hostname.toLowerCase();
if (apiBaseHost && hostname === apiBaseHost) return true;
} catch {
// malformed config — treat as no match rather than throwing
}
}
return false;
}
export function getAppIconUrl(
app: Record<string, unknown>,
deps: AppIconDeps,
size?: number,
): string | null {
const appUid = (app.uid ?? app.uuid) as string | undefined;
if (!appUid) return null;
const normalizedUid = appUid.startsWith('app-') ? appUid : `app-${appUid}`;
const iconSize = Number.isFinite(Number(size))
? Number(size)
: DEFAULT_APP_ICON_SIZE;
const normalizedApiBaseUrl = String(deps.apiBaseUrl ?? '').replace(
/\/+$/,
'',
);
if (!normalizedApiBaseUrl) {
// No API base URL configured — fall back to the raw `icon` column so
// something still renders (even if it's the unsized original).
const appIcon = app.icon;
if (typeof appIcon === 'string' && /^https?:\/\//i.test(appIcon)) {
return appIcon;
}
return null;
}
return `${normalizedApiBaseUrl}/app-icon/${normalizedUid}/${iconSize}`;
}
/**
* Base URL of the subdomain AppIconService publishes generated icons to. Keeps
* the externally-visible port on deployments not served from 80/443.
*/
export function getAppIconsBaseUrl(config: AppIconHostConfig): string | null {
const host =
config.static_hosting_domain ?? config.static_hosting_domain_alt;
if (!host) return null;
const protocol = config.protocol ?? 'https';
const pubPort = config.pub_port;
const portSuffix =
pubPort && pubPort !== 80 && pubPort !== 443 ? `:${pubPort}` : '';
return `${protocol}://${APP_ICONS_SUBDOMAIN}.${host}${portSuffix}`;
}
/**
* Direct subdomain URL for an app's icon — the target `/app-icon/<uid>/<size>`
* redirects to — or null when the app has nothing published there.
*
* Only rows whose `icon` is already an http(s) URL get one: a `data:` column
* means the resize pipeline hasn't run, so no file exists yet, and the endpoint
* serves those inline anyway. Callers ship this alongside the endpoint URL so
* clients can try it first.
*/
export function getAppIconCdnUrl(
app: Record<string, unknown>,
config: AppIconHostConfig,
size?: number,
): string | null {
const appUid = (app.uid ?? app.uuid) as string | undefined;
if (!appUid) return null;
const icon = app.icon;
if (typeof icon !== 'string' || !/^https?:\/\//i.test(icon)) return null;
const base = getAppIconsBaseUrl(config);
if (!base) return null;
const normalizedUid = appUid.startsWith('app-') ? appUid : `app-${appUid}`;
const iconSize = Number.isFinite(Number(size))
? Number(size)
: DEFAULT_APP_ICON_SIZE;
return `${base}/${normalizedUid}-${iconSize}.png`;
}