Skip to content

[REVIEW] Cross-Origin Policies: scored by presence only — unsafe-none / cross-origin get full credit #8

@BodenMcHale

Description

@BodenMcHale

Summary

checkCrossOriginPolicies in src/rules.ts scores by header presence, not by header value. A site can explicitly opt out of cross-origin isolation by setting Cross-Origin-Opener-Policy: unsafe-none, Cross-Origin-Embedder-Policy: unsafe-none, and Cross-Origin-Resource-Policy: cross-origin — the three least restrictive values, two of which are the browser defaults — and still receive the maximum score (5/5, status good) from this analyzer with zero findings or recommendations.

This is the same class of correctness bug that issue #4 fixed for Permissions-Policy: presence ≠ protection. A header analyzer cannot vouch for cross-origin isolation by checking only that the bytes exist on the wire.

Investigation Path

  1. Read every source file: src/rules.ts, src/analyzer.ts, src/fetch.ts, src/cli.ts, src/index.ts, src/types.ts.
  2. Traced checkCrossOriginPolicies (src/rules.ts lines 145–164) line-by-line and confirmed the scoring only checks Boolean(value) — no string comparison against the value at all.
  3. Hand-evaluated the function against the three least-restrictive values (COOP: unsafe-none, COEP: unsafe-none, CORP: cross-origin):
    • count = 3score = min(6, 5) = 5status: 'good'findings: []recommendations: []
  4. Checked open issues (agent-escalate: Permissions-Policy rule should require camera/microphone/geolocation to award full score #4, [REVIEW] CSP: checkCSP awards full score without verifying base-uri restriction, enabling silent <base> injection bypass #5, [REVIEW] fetchHeaders: HEAD request systematically miss-grades sites that emit security headers only on GET #7) and open PRs (test: add comprehensive test coverage + fetch timeout + CLI help/version #2, chore: add dependabot.yml for automated dependency updates #3) for overlap — none address value validation for these three headers. PR test: add comprehensive test coverage + fetch timeout + CLI help/version #2 explicitly locks in the present-but-permissive scoring with a test:
    it('one present returns score 2', () => {
      const r = checkCrossOriginPolicies({ 'cross-origin-opener-policy': 'same-origin' });
      expect(r.score).toBe(2);
    });
    The test asserts score === 2 based on presence; it would pass identically if the value were 'unsafe-none'.
  5. Cross-referenced authoritative documentation for each header's value semantics (MDN, web.dev). Confirmed unsafe-none (COOP/COEP) and cross-origin (CORP) are the opt-out / least restrictive values.
  6. Cross-referenced issue agent-escalate: Permissions-Policy rule should require camera/microphone/geolocation to award full score #4's resolution pattern (8d29a8c) — exactly the same shape of fix applies here: keep the header check but gate the points on a protective value.

Evidence

  • File: src/rules.ts lines 145–164, commit 5083c52b64190381eadbb9f0c13b42e52c358a5a

    export function checkCrossOriginPolicies(headers: RawHeaders): HeaderFinding {
      const coep = getHeader(headers, 'cross-origin-embedder-policy');
      const coop = getHeader(headers, 'cross-origin-opener-policy');
      const corp = getHeader(headers, 'cross-origin-resource-policy');
      const count = [coep, coop, corp].filter(Boolean).length;
      const score = Math.min(count * 2, 5);
      // ...
      return {
        header: 'Cross-Origin Policies', score, maxScore: 5,
        status: score >= 4 ? 'good' : score > 0 ? 'warning' : 'missing',
        // ...
      };
    }
  • Current behavior: Awards 5/5 with status: 'good' and no findings/recommendations for this header set:

    Cross-Origin-Opener-Policy: unsafe-none
    Cross-Origin-Embedder-Policy: unsafe-none
    Cross-Origin-Resource-Policy: cross-origin

    — i.e. for a site that has explicitly disabled cross-origin isolation on every dimension.

  • Expected behavior: Award credit only for protective values:

    • Cross-Origin-Opener-Policy: same-origin or same-origin-allow-popups → credit; unsafe-none → no credit + finding.
    • Cross-Origin-Embedder-Policy: require-corp or credentialless → credit; unsafe-none → no credit + finding.
    • Cross-Origin-Resource-Policy: same-origin or same-site → credit; cross-origin → no credit + finding (with note that this may be intentional for public CDN assets).
  • Reproducer (works against the current code):

    import { checkCrossOriginPolicies } from './src/rules.js';
    
    const r = checkCrossOriginPolicies({
      'cross-origin-opener-policy': 'unsafe-none',
      'cross-origin-embedder-policy': 'unsafe-none',
      'cross-origin-resource-policy': 'cross-origin',
    });
    
    console.log(r.score);            // 5  ← full marks
    console.log(r.status);           // 'good'
    console.log(r.findings);         // []
    console.log(r.recommendations);  // []
  • External references:

Impact

Every developer who relies on this tool's grade as a signal of cross-origin isolation receives a false positive. Two concrete failure modes:

  1. Misleading "good" on a deliberately opted-out site. A team that explicitly sets COOP: unsafe-none to keep cross-origin window opener access for ad/SSO integrations gets a good 5/5 — the tool gives no indication that cross-origin isolation is disabled. Without that signal, the team has no prompt to revisit the trade-off, and SharedArrayBuffer, performance.measureUserAgentSpecificMemory(), high-precision performance.now(), and other isolation-gated APIs silently fail in production with no diagnostic path back to the headers.

  2. Misleading "good" from a misconfigured middleware that emits the default. Several CDN edge frameworks (and a few helmet wrappers) emit Cross-Origin-Opener-Policy: unsafe-none as a placeholder during scaffold. The current analyzer cannot distinguish that scaffold from real protection — it just sees a header present and awards the points.

This is the symmetric counterpart of #4 (Permissions-Policy presence-vs-value). Fixing #4 without fixing this leaves a known correctness gap in the same scoring philosophy.

Severity is bounded by the relatively small maxScore: 5 weight in the overall grade, but the issue is about correctness of the per-header finding, not the overall percentage — the status: 'good' and empty findings/recommendations arrays are what mislead users, regardless of how much the points move the grade.

Suggested Remediation

Mirror the pattern issue #4 / commit 8d29a8c established for checkPermissionsPolicy: score per-header on protective values, accumulate, and emit specific findings for non-protective values.

export function checkCrossOriginPolicies(headers: RawHeaders): HeaderFinding {
  const coep = getHeader(headers, 'cross-origin-embedder-policy');
  const coop = getHeader(headers, 'cross-origin-opener-policy');
  const corp = getHeader(headers, 'cross-origin-resource-policy');

  const norm = (v: string | undefined) => v?.toLowerCase().trim();

  const coopOk = ['same-origin', 'same-origin-allow-popups'].includes(norm(coop) ?? '');
  const coepOk = ['require-corp', 'credentialless'].includes(norm(coep) ?? '');
  const corpOk = ['same-origin', 'same-site'].includes(norm(corp) ?? '');

  let score = 0;
  if (coopOk) score += 2;
  if (coepOk) score += 2;
  if (corpOk) score += 1;
  score = Math.min(score, 5);

  const findings: string[] = [];
  const recommendations: string[] = [];

  if (!coop) {
    findings.push('Cross-Origin-Opener-Policy not set');
    recommendations.push("Add Cross-Origin-Opener-Policy: same-origin");
  } else if (!coopOk) {
    findings.push(`Cross-Origin-Opener-Policy: '${coop}' does not enable cross-origin isolation`);
    recommendations.push("Set Cross-Origin-Opener-Policy to same-origin (or same-origin-allow-popups)");
  }

  if (!coep) {
    findings.push('Cross-Origin-Embedder-Policy not set');
    recommendations.push('Add Cross-Origin-Embedder-Policy: require-corp');
  } else if (!coepOk) {
    findings.push(`Cross-Origin-Embedder-Policy: '${coep}' does not enable cross-origin isolation`);
    recommendations.push('Set Cross-Origin-Embedder-Policy to require-corp (or credentialless)');
  }

  if (!corp) {
    findings.push('Cross-Origin-Resource-Policy not set');
    recommendations.push('Add Cross-Origin-Resource-Policy: same-origin');
  } else if (!corpOk) {
    findings.push(`Cross-Origin-Resource-Policy: '${corp}' is permissive (any origin can embed this resource)`);
    recommendations.push("Set Cross-Origin-Resource-Policy to same-origin or same-site (use cross-origin only for intentionally-public assets)");
  }

  return {
    header: 'Cross-Origin Policies', score, maxScore: 5,
    status: score >= 4 ? 'good' : score > 0 ? 'warning' : 'missing',
    raw: [coep && `COEP: ${coep}`, coop && `COOP: ${coop}`, corp && `CORP: ${corp}`].filter(Boolean).join('; ') || undefined,
    findings, recommendations,
  };
}

PR #2's existing cross-origin test cases use protective values (same-origin, require-corp) and continue to pass under this remediation — only tests that asserted credit for *non-*protective values would need updating. Those tests do not currently exist (PR #2 added presence-only assertions), so the change is forward-compatible.

Acceptance Criteria

  • checkCrossOriginPolicies({ 'cross-origin-opener-policy': 'unsafe-none', 'cross-origin-embedder-policy': 'unsafe-none', 'cross-origin-resource-policy': 'cross-origin' }) returns score: 0 (or whatever the team decides for permissive values) and includes a finding for each header explaining why it is not protective.
  • checkCrossOriginPolicies({ 'cross-origin-opener-policy': 'same-origin', 'cross-origin-embedder-policy': 'require-corp', 'cross-origin-resource-policy': 'same-origin' }) continues to return score: 5, status: 'good' with no findings.
  • Cross-Origin-Resource-Policy: same-site is recognized as protective (in addition to same-origin).
  • Cross-Origin-Embedder-Policy: credentialless is recognized as protective (in addition to require-corp).
  • Cross-Origin-Opener-Policy: same-origin-allow-popups is recognized as protective (in addition to same-origin).
  • New test cases in test/analyzer.test.ts cover: (a) all-unsafe-none/cross-origin opt-out path, (b) each individual permissive value, (c) the protective alternatives listed above.
  • README's "Cross-Origin Policies" row in the headers table is updated to mention that values (not just presence) are checked, mirroring the wording that surrounds the Permissions-Policy fix from agent-escalate: Permissions-Policy rule should require camera/microphone/geolocation to award full score #4.

Claude Code review routine · commit 5083c52b64190381eadbb9f0c13b42e52c358a5a · 2026-05-26T00:55:00Z

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingsecurity

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions