Skip to content
academia.sh

Lesson 20 / 27

Common Web Risks

The client-side counterparts of common vulnerability classes; authorization decisions made in the interface, open redirect, trust in data reaching the client, and secrets leaking into the bundle.

Contents

The six lessons in this topic built specific mechanisms: escaping at output, the content security policy, cross-origin resource sharing rules, request tokens, security headers, and subresource integrity. Each one corresponded to an attack class that already has a name.

Part of the problems encountered in practice fall outside these classes and share a common root: assuming the client is a security boundary. This lesson takes up common vulnerability classes through their client-side projection and gives a measurable rule for each.

Hiding in the Interface Is Not the Same as Granting Authority

In the North Slope Measurement Station interface, the button for editing a measurement record is shown only to users with the editor role. This is a correct interface decision and an incorrect security decision. The button not being visible does not mean the request it triggers cannot be sent; a request is just an address and a body.

// authorization.mjs — the independence of client-side hiding from server-side authorization
function uiShowsButton(user) {
  return user.roles.includes('editor');
}

function serverAllows(session, record) {
  if (!session.verified) return false;
  if (!session.roles.includes('editor')) return false;
  return record.station === session.station;
}

const record = { station: 'north-slope', measurementNo: 4417 };
const cases = [
  ['editor, own station', { verified: true, roles: ['editor'], station: 'north-slope' }],
  ['editor, another station', { verified: true, roles: ['editor'], station: 'south-ridge' }],
  ['reader only', { verified: true, roles: ['reader'], station: 'north-slope' }],
  ['unverified', { verified: false, roles: ['editor'], station: 'north-slope' }],
];

console.log('case'.padEnd(30), 'button visible', 'request accepted');
for (const [name, session] of cases) {
  const visible = uiShowsButton({ roles: session.roles }) ? 'yes' : 'no';
  const accepted = serverAllows(session, record) ? 'yes' : 'no';
  console.log(name.padEnd(30), visible.padEnd(15), accepted);
}
$ node authorization.mjs
case                           button visible request accepted
editor, own station            yes             yes
editor, another station        yes             no
reader only                    no              no
unverified                     yes             no

The two columns being computed separately was deliberate. The second and fourth rows are cases where the interface says “yes” but the server says “no”; the interface-side decision only sees the role, while the server-side decision also sees which station the record belongs to and whether the session has been verified.

This distinction is the root of two subclasses. Broken object-level access control is the server not performing an ownership check when a record’s identifier is changed; the problem is caught late because the interface never shows that record at all. Vertical privilege escalation is an administrative operation, hidden only in the interface, that can be called directly. Both share the same defense: every request is re-evaluated on the server, based on who is making the request and what they are accessing.

The same rule applies to validation. A form’s client-side constraint validation is a usability feature; it surfaces an error to the user early. The check that guarantees data integrity is the one on the server, and the client-side rule’s presence does not make it unnecessary.

Open Redirect

The sign-in flow sends the user back to the page they came from after verification. The target address is usually carried in the query string, and if it is not validated, the application turns into a tool that redirects to any address. The harm is not direct: the application’s own domain gets used to send the user somewhere else.

// redirect.mjs — validates the return-navigation target against an allowlist
const OWN_ORIGIN = 'https://measurements.example.test';
const ALLOWED_ORIGINS = new Set([OWN_ORIGIN, 'https://report.example.test']);
const DEFAULT = OWN_ORIGIN + '/station';

function returnTarget(raw) {
  if (typeof raw !== 'string' || raw === '') return DEFAULT;
  let url;
  try {
    url = new URL(raw, OWN_ORIGIN);
  } catch {
    return DEFAULT;
  }
  if (!['http:', 'https:'].includes(url.protocol)) return DEFAULT;
  if (!ALLOWED_ORIGINS.has(url.origin)) return DEFAULT;
  return url.href;
}

const candidates = [
  '/station/north-slope?day=12',
  'https://report.example.test/summary',
  'https://other.example.test/login',
  '//other.example.test/login',
  'https://measurements.example.test.other.test/login',
  'measurement-app://open',
  '',
];

for (const candidate of candidates) {
  console.log(returnTarget(candidate).padEnd(52), '<-', candidate === '' ? '(empty)' : candidate);
}
$ node redirect.mjs
https://measurements.example.test/station/north-slope?day=12 <- /station/north-slope?day=12
https://report.example.test/summary                  <- https://report.example.test/summary
https://measurements.example.test/station            <- https://other.example.test/login
https://measurements.example.test/station            <- //other.example.test/login
https://measurements.example.test/station            <- https://measurements.example.test.other.test/login
https://measurements.example.test/station            <- measurement-app://open
https://measurements.example.test/station            <- (empty)

The fourth and fifth lines show why this check cannot be written as a string comparison. An address that starts with two slashes and no scheme goes to a different host while inheriting the page’s scheme; a “starts with our own domain name” criterion, meanwhile, lets measurements.example.test.other.test through. The comparison is made on the origin component of the resolved address, and any input that cannot be validated falls through to a fixed default, not to the allowlist.

Trust in Data Reaching the Client

A preference read from local storage, a state decoded from an address fragment, or a configuration object from an endpoint is not data the application produced itself. Code that merges this data into application objects has to assume the key names are data too.

// merge.mjs — merges externally sourced settings without touching the prototype chain
const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']);

function safeMerge(base, incoming) {
  const result = Object.create(null);
  for (const [key, value] of Object.entries(base)) result[key] = value;
  for (const [key, value] of Object.entries(incoming)) {
    if (DANGEROUS_KEYS.has(key)) {
      console.log('  skipped key:', key);
      continue;
    }
    result[key] = value;
  }
  return result;
}

const defaults = { unit: 'celsius', threshold: -5, autoRefresh: true };
const fromRecord = JSON.parse('{"threshold": -12, "__proto__": {"admin": true}, "unit": "kelvin"}');

console.log('merging:');
const settings = safeMerge(defaults, fromRecord);

console.log('result:', JSON.stringify(settings));
console.log('prototype chain:', Object.getPrototypeOf(settings));
console.log('admin field on an unrelated object:', ({}).admin);
$ node merge.mjs
merging:
  skipped key: __proto__
result: {"unit":"kelvin","threshold":-12,"autoRefresh":true}
prototype chain: null
admin field on an unrelated object: undefined

The prototype chain introduced in the Objects and Functions in JavaScript course turns into an attack surface here: a field written to a shared prototype becomes visible on every object descending from that prototype. The defense has two parts. Keys that reach the chain are skipped during the merge, and the target object is created as a null-prototype object; the last line confirms that an unrelated object was not affected.

Another application of the same principle is validating external data against a schema. The approach built in the Validation Schemas lesson of the Application Architecture course gains security value here: a schema that lists the expected fields structurally filters out unexpected keys.

Secrets Leaking into the Bundle

Everything sent to the client is public. This sentence is not a warning; it is a definition: the bundle file can be downloaded, the network panel can be opened, and a source map can be read.

Three rules follow from this. A secret key for an external service does not go into the client bundle; calls the client needs go through its own server. Configuration meaningful only on the client and secrets meaningful only on the server are kept in separate places, and which variables the build tool embeds into the client bundle is checked explicitly. Whether source maps are served publicly in production is a conscious decision; if they are needed for error tracking, they are handed to the error-collection layer, not to the browser.

The same rule applies to the error surface. Error messages returned to the client do not carry internal structure information; error records collected from the client can carry user data and have to be scrubbed before collection.

Reading the Classes Together

The common thread across the seven lessons in this topic comes down to a single measure: where a check runs determines how much it can be trusted. Every check that runs on the client serves the user experience and is under the user’s control. The checks the browser enforces — the same-origin policy, the content security policy, cookie attributes, integrity validation — rest on rules the server declared and protect the user’s browser. Checks that run on the server protect the application’s data, and none of the others substitute for them.

Mixing up these three layers is the common cause behind most of the vulnerability classes covered in this topic.

Summary

  • Hiding a check in the interface is not authorization; every request is re-evaluated on the server, based on who is making it and what record it reaches.
  • Client-side constraint validation is for usability; data integrity is guaranteed by server-side validation.
  • A redirect target is checked against an allowlist through the origin of the resolved address; input that cannot be validated falls through to a fixed default.
  • Key names on objects coming from external sources are data too; the merge skips keys that reach the prototype chain and builds the target as a null-prototype object.
  • Everything sent to the client is public; secret keys do not go into the bundle, and publishing source maps is a conscious decision.
  • How much a check can be trusted depends on where it runs: the client, browser, and server layers do not substitute for one another.

Next Step

The defenses built across this topic share a common weakness: every one of them can break silently. An escape point gets missed, a policy directive gets loosened, a cookie attribute drops, an integrity value is left unupdated — and the interface keeps working in every case. The last axis of quality is therefore measurement. The next topic opens with tests that verify behavior stays as expected: the Unit and Component Tests lesson builds the rules for testing what components actually do, through their behavior.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close