-
Notifications
You must be signed in to change notification settings - Fork 61
security: scope CORS to loopback + block cross-origin config/stop (PER-8600/8601/8602/8603) #2282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
c9ef2d5
a3e54d0
69036bf
c012ddd
b2668d7
2ffd545
82f4f0e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,25 @@ | |
| compile as makeToPath | ||
| } from 'path-to-regexp'; | ||
|
|
||
| // Returns true when an Origin header value resolves to a loopback host. The | ||
| // local Percy server is only ever legitimately reached by Node SDK clients | ||
| // (which send no Origin) or by loopback browser tooling (e.g. the Storybook | ||
| // manager on localhost:<port>). Treating non-loopback origins as untrusted lets | ||
| // us scope CORS and reject cross-origin state changes (CWE-942 / CWE-352). | ||
| export function isLoopbackOrigin(origin) { | ||
| if (!origin || typeof origin !== 'string') return false; | ||
| try { | ||
| // URL.hostname KEEPS the brackets around an IPv6 literal, so `[::1]` | ||
| // stays `[::1]` here and must be unwrapped before matching `::1`. | ||
| let host = new URL(origin).hostname.toLowerCase(); | ||
| if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1); | ||
| return host === 'localhost' || host === '127.0.0.1' || | ||
| host === '::1' || host.endsWith('.localhost'); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| // custom incoming message adds a `url` and `body` properties containing the parsed URL and message | ||
| // buffer respectively; both available after the 'end' event is emitted | ||
| export class IncomingMessage extends http.IncomingMessage { | ||
|
|
@@ -218,19 +237,36 @@ | |
| #routes = [{ | ||
| priority: -1, | ||
| handle: (req, res, next) => { | ||
| res.setHeader('Access-Control-Allow-Origin', '*'); | ||
| // Only echo a loopback Origin back as Access-Control-Allow-Origin. The | ||
| // previous wildcard `*` let any website the user visited read responses | ||
| // from the local Percy server (build data, config) cross-origin (CWE-942). | ||
| let origin = req.headers.origin; | ||
| let originAllowed = isLoopbackOrigin(origin); | ||
| // The response branches on Origin on every path (ACAO is emitted only for | ||
| // loopback origins), so Vary: Origin must be set unconditionally — | ||
| // otherwise a cache/intermediary could serve a no-ACAO body to a loopback | ||
| // origin, or a loopback body to another origin. | ||
| res.setHeader('Vary', 'Origin'); | ||
| if (originAllowed) { | ||
| // `origin` is validated against a loopback-only allowlist above, so it | ||
| // is not attacker-controlled here. | ||
| // nosemgrep: javascript.express.security.cors-misconfiguration.cors-misconfiguration | ||
| res.setHeader('Access-Control-Allow-Origin', origin); | ||
Check warningCode scanning / Semgrep OSS Semgrep Finding: javascript.express.security.cors-misconfiguration.cors-misconfiguration Warning
By letting user input control CORS parameters, there is a risk that software does not properly verify that the source of data or communication is valid. Use literal values for CORS settings.
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3 (cache-correctness, low risk for a localhost server).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 82f4f0e. |
||
| } | ||
|
|
||
| if (req.method === 'OPTIONS') { | ||
| let allowHeaders = req.headers['access-control-request-headers'] || '*'; | ||
| let allowMethods = [...new Set(this.#routes.flatMap(route => ( | ||
| (!route.match || route.match(req.url.pathname)) && route.methods | ||
| ) || []))].join(', '); | ||
|
|
||
| res.setHeader('Access-Control-Allow-Headers', allowHeaders); | ||
| res.setHeader('Access-Control-Allow-Methods', allowMethods); | ||
| if (originAllowed) { | ||
| let allowHeaders = req.headers['access-control-request-headers'] || '*'; | ||
| let allowMethods = [...new Set(this.#routes.flatMap(route => ( | ||
| (!route.match || route.match(req.url.pathname)) && route.methods | ||
| ) || []))].join(', '); | ||
|
|
||
| res.setHeader('Access-Control-Allow-Headers', allowHeaders); | ||
| res.setHeader('Access-Control-Allow-Methods', allowMethods); | ||
| } | ||
| res.writeHead(204).end(); | ||
| } else { | ||
| res.setHeader('Access-Control-Expose-Headers', '*'); | ||
| if (originAllowed) res.setHeader('Access-Control-Expose-Headers', '*'); | ||
| return next(); | ||
| } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.