Client/Server Interface

Dated Apr 19, 2024; last modified on Fri, 19 Apr 2024

How to handle redirects without setting window.location.href?

Right now, there’s a pattern of doing:

sendHTTPRequest("POST", "/login/", {})
  .then((_) => {
      window.location.href = "/";
  })
  .catch((err) => { console.error(err); });

Isn’t this something that the server can do? In response, why not issue a redirect?

Screenshot of the redirect chain from /login. The POST
request gets a 303 (See Other) redirect to /home. The browser then
makes a GET request to /home, which results in a 304 (Not Modified).
Why doesn’t the browser navigate me to /home?

Screenshot of the redirect chain from /login. The POST request gets a 303 (See Other) redirect to /home. The browser then makes a GET request to /home, which results in a 304 (Not Modified). Why doesn’t the browser navigate me to /home?

The 304 Not Modified response is sent when there is no need to retransmit the requested resources. In this case, the page at /home has not changed, and the browser can use the cached version. If the content had changed, then the browser would have requested the resource from the server, resulting in a 200 response.

Installing http-status-codes for more legible status codes.

This issue does not happen when I issue a GET request to /logout by clicking on a <a href="/logout">Log Out</a>. The server responds with a 303 (See Other) to /browse, and the browser loads /browse in the tab. If I do the same with fetch('/logout', { method: 'GET' }) instead, then the browser does not navigate the page to /browse despite the /browse response being observable in the Network tab. The consensus online is that fetch responses do not lead to automatic browser navigation.

Re: fetch vs. XMLHttpRequest. The latter is the older way of making HTTP requests from JavaScript without leaving the page; it is based on events. fetch is built around promises, which are considered the preferred way of doing asynchronous operations. fetch and XMLHttpRequest are implemented by browser vendors. On top of these, numerous libraries exist, e.g., Fetch polyfill, isomorphic-fetch, axios, jQuery, etc., to fill various gaps, e.g., different syntax, varying browser support, etc.

Typing the Client/Server Interface

One source of bugs is the server and the client being out of sync w.r.t. the shape of the data being exchanged. Granted, I have both the server and the client in the same repo, how can I avoid mismatches in data? Is there a library or a standard way of creating an interface for my api endpoints? : typescript floats tRPC, GraphQL, OpenAPI, and some others, with tRPC coming in first and GraphQL as the incumbent.

GitHub Copilot can help out here with quick intros on what tRPC and GraphQL entail. With GraphQL, the client can strongly type the API, define the data payloads, fetch all the data in one request, query for supported types, and subscribe to real-time updates. GraphQL is intended to be an improvement over REST. tRPC is designed with TypeScript in mind. Advantages of tRPC include no schema syncing as the input/output is inferred directly from function signatures, and can work with GraphQL (or any other data fetching method). In the case of my app, using tRPC involves replacing Express routes with tRPC procedures. Trying tRPC!

Impressed by Copilot. Definitely saved me time on this one.

What’s the use case for tRPC for endpoints that return a static page in response to a GET request? Copilot says that serving HTML files should be left to traditional web frameworks like Express as those have built-in support for SSR of HTML.

Adding tRPC endpoints that don’t need authentication was mostly mechanical. However, there were a few gotchas. Some methods/props on a Mongoose document (e.g., the Document interface) cannot be shared between the server and the client, and so we need to define a safe interface that can be inferred by tRPC and availed on the client side. We also encountered a challenge in typing the inputs, given that the app relies on Mongoose validation that occurs after tRPC has seen the inputs. Ended up adding a passthrough input parser, e.g.,

export const authRouter = router({
  registerUser: publicProcedure
    .input((params: unknown) => params as RegisterUserAndPasswordParams)
    .mutation(({ input }) => {
      return registerUserAndPassword(input);
    }),
});

The docs are not explicit about this because not validating is considered unsafe, and the tRPC maintainers don’t want to advocate for it. With the base knowledge from non-auth endpoints and reading the docs several times, adding auth-dependent endpoints integrated nicely with Express’s session management.

Augmenting Server Objects Client Side

For example, the server transmits JSON data of the ICardRaw shape. Is it possible to add helper methods in the client for ease of use? suggests that we can do:

// server-side-card.ts
export class Card {}

// client-side-card.ts
import { Card } from '../../server-side-card.js';
declare module '../../server-side-card.js' {
  interface Card {
    get formattedTags(): string[];
  }
}

Card.prototype.formattedTags = function() {
  return tags.split(' ').filter(Boolean).map(t => `#${t}`);
}

// consumer.ts
import { Card } from '../../server-side-card.js';
import 'client-side-card.js';

let c: Card;
console.log(c.formattedTags);

However, we have type PrivateCardResult = RouterOutput['fetchCard'][0] in trpc.ts. While we can extend the type , it doesn’t seem like we can add dynamic properties of the form:

type Foo = { s: string };
interface FooDeluxe extends Foo {
  emphasized: () => string;
}
// 'FooDeluxe' only refers to a type, but is being used as a value here.ts(2693)
FooDeluxe.prototype.emphasized = function() {
  return this.s.toUpperCase();
}

Foregoing tRPC’s typed procedures is not worth it.

Validating Inputs

We use zod (vouched for by tRPC) to parse/validate the input on the server. The “parse, don’t validate” mantra resonates as something to apply to programming projects: instead of validating input, parse it such that incorrect state is impossible to represent.

zod and tRPC work especially well:

const fetchCardParamsValidator = z.object({
  cardID: z.string().refine(isMongoId, { message: "Invalid card ID" }),
});

const inAppRouter = router({
  fetchCard: authedProcedure
    .input(fetchCardParamsValidator)
    .query(({ input, ctx }) => {
      return CardsDB.read({ ...input, userIDInApp: ctx.user.userIDInApp });
    }),
});

Executing fetchCard({cardID: { $ne: "000000000000000000000000" }}) leads to a TRPCError caused by a ZodError. At the input stage, we did not just validate that input is of the correct shape, we discarded inputs that were not of the correct shape. CardsDB.read no longer needs to worry that cardID could be corrupted.

Typical Express middleware falls short of the “parse, don’t validate” approach because the type information is not inferrable from the request object. For this, we fallback to assuming that the validation middleware was applied, and it’s safe to trust request.body.

Cross-Site Request Forgery (CSRF) Prevention

In a CSRF attack, the attacker wants the victim to perform state-changing requests unknowingly. Suppose Alice is logged into bank.com in her browser. To send money to Bob, the request is of the form GET http://bank.com/transfer.do?acct=BOB&amount=100 HTTP/1.1. Maria, the attacker, send Alice an email with an invisible picture: <img src="http://bank.com/transfer.do?acct=MARIA&amount=100000" width="0" height="0" border="0">. When Alice opens the email, the browser will try loading the image, thereby submitting the transfer request to bank.com, without Alice’s knowledge.

Token-based mitigation involves the server setting a CSRF token, which the client then echoes back. The server rejects the request if the echoed token is not valid. Because CSRF tokens are ephemeral, the chance that the token will still be valid when an attacker attempts a CSRF attack is reduced. Session-based tokens offer a good compromise between security and usability. Request-based tokens have usability concerns, e.g., breaking when the user uses the back button.

The lusca package provides CSRF protection for Express apps. lusca docs were lacking as they showed how to configure CSRF protection on the server but not on the client, leaving me with 403 errors to handle.

One technique is to use <form> tags:

<form action="/transfer.do" method="post">
<input type="hidden" name="_csrf" value="OWY4NmQwODE4ODRjN2Q2NTlhMmZlYWEwYzU1YWQwMTVhM2JmNGYxYjJiMGI4MjJjZDE1ZDZMGYwMGEwOA==">
[...]
</form>

. This approach works well for forms rendered through response.render.

What about for tRPC endpoints? A breakpoint in node_modules/lusca/lib/csrf.js shows that the CSRF token is looked for in either req.body["_csrf"] or req.headers["x-csrf-token"]. tRPC sends payloads of the form:

{
  "0": {
    "_id": "6682027e7c0aca290ed4ab4c",
    "title": "Modified title",
  }
}

… where 0 is a tRPC internal; the client code for the above call is something like trpc.updateCard.mutate({_id, title}). tRPC supports computing custom headers per request . By setting the CSRF token in a <meta> element, and then reading that before issuing the tRPC call, we’re able to have tRPC calls succeed.

What about testing? The client-side tests uses a dev server , which doesn’t come with CSRF enforcement. The server-side uses an actual server instance, and it seems that each state-mutating test would need to pass a CSRF token. Maybe we can have a few tests that specifically check CSRF protections, and then disable CSRF for the rest of the tests?

References

  1. 304 Not Modified - HTTP | MDN. developer.mozilla.org . Accessed Apr 19, 2024.
  2. javascript - Difference between fetch, ajax, and xhr - Stack Overflow. stackoverflow.com . Accessed Apr 19, 2024.
  3. AJAX/HTTP Library Comparison. www.javascriptstuff.com . Feb 3, 2016. Accessed Apr 19, 2024.
  4. [Next.JS] x.data.user is of type 'unknown' · trpc/trpc · Discussion #3661. github.com . Accessed Apr 20, 2024.
  5. docs: Howto add typed input (without validation) · Issue #3339 · trpc/trpc. github.com . Accessed Apr 20, 2024.
  6. TypeScript: Documentation - Declaration Merging > Module Augmentation. www.typescriptlang.org . Accessed Apr 30, 2024.
  7. javascript - Possible to extend types in Typescript? - Stack Overflow. stackoverflow.com . Accessed May 3, 2024.
  8. Zod | Documentation. zod.dev . Accessed Jun 30, 2024.
  9. [Major] Add server-side input validation by dchege711 · Pull Request #182 · dchege711/study_buddy. github.com . Accessed Jun 30, 2024.
  10. Parse, don't validate. Alexis King. lexi-lambda.github.io . Nov 5, 2019. Accessed Jun 30, 2024.
  11. Using Express middleware. expressjs.com . Accessed Jun 30, 2024.
  12. Cross Site Request Forgery (CSRF) | OWASP Foundation. owasp.org . Accessed Jun 30, 2024.
  13. Missing CSRF middleware — CodeQL query help documentation. codeql.github.com . Accessed Jun 30, 2024.
  14. krakenjs/lusca: Application security for express apps. github.com . Accessed Jun 30, 2024.
  15. Cross-Site Request Forgery Prevention - OWASP Cheat Sheet Series. cheatsheetseries.owasp.org . Accessed Jun 30, 2024.
  16. Custom header | tRPC. trpc.io . Accessed Jun 30, 2024.