svelte, skjemaer, og å aldri la nettleseren holde på tokenet

25. juni 2026 meta devlogsveltekit

fra React til SvelteKit

Dette er det nyeste verktøyet i hele stacken for meg, og også det som gjør flest jobber på én gang. SvelteKit rendrer sider på serveren, håndterer skjemainnsendinger, og proxyer API-kall til de to Go-tjenestene, alt fra én Node-prosess. Kommer man fra en stort sett React- og MERN-stack-bakgrunn før dette, tok det litt tilvenning at et sidelast og en skjemainnsending begge bor i samme fil, med en load-funksjon og et actions-objekt rett ved siden av hverandre. Det er ikke en single-page-app som kaller et REST-API fra klientside-JavaScript, det meste av det viktige her rører aldri nettleserens egen script-motor i det hele tatt.

hvordan én forespørsel faktisk kommer seg fra nettleseren til en Go-tjeneste

nettleseren holder aldri faktisk på tokenet

Dette er den ene arkitektoniske avgjørelsen jeg er mest beskyttende overfor i hele dette prosjektet. Access-tokenet som beviser hvem du er bor i en httponly-cookie, som betyr at ingen JavaScript som kjører i nettleseren, ikke min, ikke en nettleserutvidelse, ikke en XSS-payload hvis en noen gang skulle slippe gjennom, kan lese det. Men det må likevel komme seg fra den cookien og inn i Authorization-headeren Go-tjenestene forventer, og den oversettelsen skjer helt og holdent på serveren, inne i SvelteKit.

Hver forespørsel til backend går gjennom en catch-all proxy-rute på frontend-serveren. Nettleseren kaller /api/whatever på frontendens egen origin, SvelteKit leser cookien serverside og fester den som et ekte bearer-token før forespørselen sendes videre:

export async function forwardRequest(
	event: RequestEvent,
	target: URL,
	extraHeaders: Record<string, string> = {}
): Promise<Response> {
	const allowed = getAllowedOrigins();
	if (!isAllowedTarget(target, allowed)) {
		throw new Error(`target origin not in allowed origins: ${target.origin}`);
	}

	const headers = new Headers(extraHeaders);
	const contentType = event.request.headers.get('content-type');
	if (contentType) headers.set('content-type', contentType);
	...
	return fetch(target, init);
}

Den isAllowedTarget-sjekken mot en fast tillatelsesliste av interne origins er der slik at en bug et sted lenger oppe i kjeden ikke kan lures til å videresende en forespørsel til en vilkårlig host med auth-headerne våre stiftet på, i praksis SSRF-beskyttelse for noe som på overflaten bare ser ut som en vanlig proxy-funksjon.

sesjonstilstand uten å noensinne verifisere, med vilje

Det andre stedet tokens forvirret meg en stund: hooks.server.ts kjører på hver eneste forespørsel og bestemmer hvem som er innlogget for den forespørselen, men den bare dekoder JWT-en, den verifiserer ikke signaturen i det hele tatt.

export const handle: Handle = async ({ event, resolve }) => {
	event.locals.user = null;

	let sessionFromAccessToken = false;
	const accessToken = event.cookies.get('access_token');
	if (accessToken) {
		const claims = decodeAccessToken(accessToken);
		if (claims && !isExpired(claims)) {
			event.locals.user = claimsToLocalsUser(claims);
			sessionFromAccessToken = true;
		}
	}

	if (!sessionFromAccessToken) {
		const refreshToken = event.cookies.get('refresh_token');
		if (refreshToken) {
			const response = await event.fetch('/auth/refresh', { method: 'POST' });
			...
		}
	}
	...
};

Leser som et sikkerhetshull første gangen du ser på det, og det ville det vært, hvis locals.user noensinne ble behandlet som bevis på noe. Det blir den ikke. Den bestemmer bare hva siden rendrer: om admin-lenken dukker opp i navigasjonen, om et skjema viser "logg ut" i stedet for "logg inn." Hver eneste faktiske skriving går fortsatt gjennom Go-tjenestene, og det er de som verifiserer signaturen mot auths offentlige nøkler før de gjør noe med den. Hvis noen forfalsket en cookie som påsto å være admin, ville UI-en gladelig vise dem admin-navigasjonslenken, og deretter ville hver eneste forespørsel de laget bli avvist av den ekte sjekken i den andre enden uansett. Kosmetisk på denne siden, bærende på den andre.

en form action, ikke et fetch-kall

Innlogging er et vanlig HTML-skjema, sendt inn som en SvelteKit form action, noe som betyr at det fortsetter å fungere selv med JavaScript skrudd av:

export const actions: Actions = {
	default: async ({ request, fetch, url }) => {
		const form = await request.formData();
		const username = String(form.get('username') ?? '');
		const password = String(form.get('password') ?? '');

		let response: Response;
		try {
			response = await fetch('/auth/login', {
				method: 'POST',
				headers: { 'content-type': 'application/json' },
				body: JSON.stringify({ username, password })
			});
		} catch (err) {
			const apiError = await parseApiError(err);
			return fail(502, { error: apiError.message, requestId: apiError.requestId, username });
		}

		if (!response.ok) {
			const apiError = await parseApiError(response);
			return fail(response.status, { error: apiError.message, requestId: apiError.requestId, username });
		}

		throw redirect(303, safeRedirectTarget(url.searchParams.get('redirectTo')));
	}
};

Det fetch-kallet er SvelteKits egen serverside-fetch, som kjører inne i form actionen, og treffer den interne /auth/login-ruten som selv er proxyen fra over. Nettleseren ser aldri noe av dette bortsett fra en vanlig skjema-POST og en redirect til slutt.

Første gang jeg koblet dette opp prøvde jeg stadig å kalle auth-tjenesten direkte fra et <script> i komponenten slik jeg ville gjort i React:

<script lang="ts">
	// what I tried first, straight out of a React habit
	async function login(username: string, password: string) {
		const res = await fetch('http://localhost:8081/auth/login', {
			method: 'POST',
			body: JSON.stringify({ username, password })
		});
	}
</script>

Traff CORS gang på gang, siden det er en nettleser som kaller en helt annen origin direkte, og til slutt godtok jeg at nei, sånn fungerer det ikke her, serveren gjør det i stedet.

markdown som ikke kan kjøre skript

Både innleggsinnhold og kommentarer skrives som markdown og rendres til HTML, som betyr at brukerinput til slutt blir ekte DOM på siden. Det er læreboka-oppsettet for lagret XSS hvis du ikke er forsiktig med det. Rendring går gjennom marked og så rett gjennom DOMPurify med en eksplisitt tillatelsesliste, ikke en blokkeringsliste:

const POST_ALLOWED_TAGS = [
	'p', 'br', 'strong', 'em', 'del', 'a', 'ul', 'ol', 'li',
	'h2', 'h3', 'h4', 'blockquote', 'code', 'pre', 'span',
	'img', 'video', 'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
];

const COMMENT_ALLOWED_TAGS = ['p', 'br', 'a', 'strong', 'em', 'code'];
const COMMENT_ALLOWED_ATTR = ['href'];

Innlegg får en bredere tillatelsesliste enn kommentarer, fordi bare admins skriver innlegg, blogs eget API avviser alle andre på opprett/oppdater-endepunktet, så jeg stoler mer på det innholdet. Kommentarer kommer fra anonyme fremmede på internett, så de får den minste listen, ingen bilder, ingen styling, knapt mer enn et avsnitt og en lenke. Selv video-taggen får en ekstra sjekk oppå tillatelseslisten, en hook som bare lar et <video>-element beholde src-en sin hvis den URL-ens origin matcher vår egen mediehost, slik at en kommentar ikke kan peke en video-tag mot et helt annet domene.

Lite system, men det er det ene stedet jeg følte at jeg faktisk forsto et ekte sikkerhetsprinsipp (tillatelsesliste slår blokkeringsliste, hver eneste gang) i stedet for bare å bli fortalt det i en forelesning og nikke med.

0 kommentarer

Logg inn for å kommentere.

Logg inn

Ingen konto?