fix(server): gate cost/log endpoints against cross-site requests and ship a full CSP
Four dev-server endpoints were reachable cross-site from any page open in the same browser: /api/realtime/token, /api/openai/hud-summary, /api/google/nearby-places and /api/realtime/debug-log. A fetch carries a foreign Origin; an <img> carries no Origin but Sec-Fetch-Site: cross-site. Add a pure, unit-tested admitSameSiteRequest gate (proxy-signal headers, Sec-Fetch-Site other than same-origin/none, foreign or opaque Origin all refuse with 403) and call it in the four handlers. It does not require a loopback socket, an Origin, or JSON, so the Node QA harnesses and the HOST=0.0.0.0 LAN opt-in keep working. PROXY_SIGNALS is now one shared export used by both gates. Replace the frame-ancestors-only header with a full Content-Security-Policy on dev and preview. script-src is 'self' 'unsafe-eval': Knockout inside @cesium/widgets resolves the global object with eval at load time and the Cesium widget does not initialize without it (verified in headless Chrome). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
759652207f
commit
b31267c629
|
|
@ -28,7 +28,7 @@ These are designed to be used directly in the browser (like a Mapbox public toke
|
|||
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you.
|
||||
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
|
||||
|
||||
> The Vite `define` block in `vite.config.js` controls exactly what reaches the client: only these two keys plus two non-secret CCTV feature flags. Everything else stays server-side.
|
||||
> The Vite `define` block in `vite.config.js` controls exactly what reaches the client: only these two keys. Everything else stays server-side.
|
||||
|
||||
Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file.
|
||||
|
||||
|
|
@ -52,6 +52,8 @@ The data proxies in `vite.config.js` are written so the browser cannot turn the
|
|||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`).
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval'`: no inline script and no script from any other origin), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
## Network exposure — the operator threat model
|
||||
|
||||
|
|
|
|||
|
|
@ -101,6 +101,17 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|||
/** Socket addresses that count as this machine. */
|
||||
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
/**
|
||||
* Reverse-proxy / CDN forwarding headers. Their presence means the request did
|
||||
* not originate on this machine, whatever its socket says. Shared with the
|
||||
* cost/log endpoint gate in localRequestGate.mjs so there is one definition.
|
||||
*/
|
||||
export const PROXY_SIGNALS = Object.freeze([
|
||||
'forwarded', 'via', 'x-forwarded-for', 'x-forwarded-host',
|
||||
'x-forwarded-port', 'x-forwarded-proto', 'x-real-ip',
|
||||
'cf-connecting-ip', 'cf-ray',
|
||||
]);
|
||||
|
||||
/** Parse an exact local request authority from a Host header. */
|
||||
function localAuthority(hostHeader, protocol) {
|
||||
const raw = String(hostHeader || '').trim().toLowerCase();
|
||||
|
|
@ -200,7 +211,6 @@ export function admitKeySetupRequest({
|
|||
// on this machine, whatever its socket says. Refuse them outright as defense
|
||||
// in depth — the shipped tunnel (Pinokio) is force-closed at boot, so these
|
||||
// only appear when someone has deliberately fronted the dev server.
|
||||
const PROXY_SIGNALS = ['forwarded', 'via', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto', 'x-real-ip', 'cf-connecting-ip', 'cf-ray'];
|
||||
if (PROXY_SIGNALS.some((name) => String(proxyHeaders[name] || '').trim() !== '')) {
|
||||
return { ok: false, status: 403, error: 'Provider Settings does not answer proxied requests' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Same-site request gate for the cost-bearing and log endpoints.
|
||||
*
|
||||
* The credential panel has its own stricter gate (`admitKeySetupRequest` in
|
||||
* keySetupCore.mjs): loopback socket + local Host + exact Origin + JSON
|
||||
* Content-Type. The four endpoints here — `/api/realtime/token` (mints an
|
||||
* OpenAI Realtime token = spends money), `/api/openai/hud-summary` (OpenAI
|
||||
* spend), `/api/google/nearby-places` (Google spend), and
|
||||
* `/api/realtime/debug-log` (appends to a local JSONL) — were previously
|
||||
* ungated, so a hostile web page open in the same browser could reach them
|
||||
* cross-site: a `fetch` carries a foreign `Origin`, and an `<img>`/navigation
|
||||
* carries NO `Origin` at all but does carry `Sec-Fetch-Site: cross-site`.
|
||||
*
|
||||
* This module is the pure core that refuses those shapes while keeping the
|
||||
* documented `HOST=0.0.0.0` LAN opt-in and non-browser loopback tools (the two
|
||||
* repo QA harnesses POST the token endpoint from Node with no Origin) working.
|
||||
* It deliberately does NOT require a loopback remote address (that belongs only
|
||||
* to the credential panel), the presence of an `Origin`, or a JSON
|
||||
* Content-Type. It touches nothing but its arguments, so every refusal below is
|
||||
* pinned by a unit assertion.
|
||||
*/
|
||||
import { PROXY_SIGNALS } from './keySetupCore.mjs';
|
||||
|
||||
/**
|
||||
* Compute a request's own authority (an origin string) from its protocol and
|
||||
* Host header. Unlike the credential-panel gate's localhost-restricted
|
||||
* `localAuthority`, this returns whatever Host the request actually carries —
|
||||
* the LAN opt-in serves a non-loopback Host to LAN browsers, and a same-origin
|
||||
* fetch from such a page must still match. Malformed/missing input yields null.
|
||||
* @param {string} hostHeader e.g. `req.headers.host`
|
||||
* @param {string} protocol `http:` or `https:`
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function requestAuthority(hostHeader, protocol) {
|
||||
const raw = String(hostHeader || '').trim().toLowerCase();
|
||||
const scheme = String(protocol || '').toLowerCase();
|
||||
if (!raw || !['http:', 'https:'].includes(scheme) || /[\s/?#@]/.test(raw)) return null;
|
||||
try {
|
||||
return new URL(`${scheme}//${raw}`).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a request to a cost-bearing/log endpoint is same-site enough
|
||||
* to admit. Pure: no I/O, no globals.
|
||||
*
|
||||
* Policy, in order:
|
||||
* 1. any reverse-proxy / CDN signal header present (PROXY_SIGNALS) → 403;
|
||||
* 2. `Sec-Fetch-Site` present and not `same-origin` / `none` → 403 (this is
|
||||
* what blocks `<img src=...>` and cross-site navigation, which carry no
|
||||
* Origin but do carry `Sec-Fetch-Site: cross-site`);
|
||||
* 3. `Origin` present must exactly equal the request's own authority computed
|
||||
* from protocol + Host — the same exact-origin comparison the credential
|
||||
* gate uses (no userinfo, no path/search/hash). The literal string `null`
|
||||
* is an opaque origin (sandboxed iframe / `data:` URL) and is refused;
|
||||
* 4. otherwise ok. Non-browser loopback tools and the LAN opt-in (which may
|
||||
* carry neither `Origin` nor `Sec-Fetch-Site`) pass here.
|
||||
*
|
||||
* @param {{method?: string, hostHeader?: string, protocol?: string, origin?: string, secFetchSite?: string, proxyHeaders?: Record<string,string>}} req
|
||||
* @returns {{ok: true} | {ok: false, status: 403, error: string}}
|
||||
*/
|
||||
export function admitSameSiteRequest({
|
||||
method,
|
||||
hostHeader,
|
||||
protocol = 'http:',
|
||||
origin,
|
||||
secFetchSite,
|
||||
proxyHeaders = {},
|
||||
} = {}) {
|
||||
// (1) A request carrying reverse-proxy / CDN forwarding headers did not
|
||||
// originate on this machine, whatever its socket says.
|
||||
if (PROXY_SIGNALS.some((name) => String(proxyHeaders[name] || '').trim() !== '')) {
|
||||
return { ok: false, status: 403, error: 'Proxied requests are not accepted' };
|
||||
}
|
||||
// (2) The browser tells us when a request is cross-site. `none` is a typed
|
||||
// URL / bookmark; `same-origin` is the app itself. Anything else (cross-site,
|
||||
// same-site but cross-origin) is refused.
|
||||
const site = String(secFetchSite || '').trim().toLowerCase();
|
||||
if (site !== '' && site !== 'same-origin' && site !== 'none') {
|
||||
return { ok: false, status: 403, error: 'Cross-site requests are not accepted' };
|
||||
}
|
||||
// (3) If Origin is present it must exactly equal the request's own authority.
|
||||
if (origin !== undefined && origin !== null && origin !== '') {
|
||||
if (origin === 'null') {
|
||||
return { ok: false, status: 403, error: 'Opaque origins are not accepted' };
|
||||
}
|
||||
const authority = requestAuthority(hostHeader, protocol);
|
||||
let parsedOrigin;
|
||||
try {
|
||||
parsedOrigin = new URL(String(origin));
|
||||
} catch {
|
||||
return { ok: false, status: 403, error: 'Unrecognized Origin refused' };
|
||||
}
|
||||
const exactOrigin = parsedOrigin.username === ''
|
||||
&& parsedOrigin.password === ''
|
||||
&& parsedOrigin.pathname === '/'
|
||||
&& parsedOrigin.search === ''
|
||||
&& parsedOrigin.hash === ''
|
||||
&& parsedOrigin.origin === authority;
|
||||
if (!exactOrigin) {
|
||||
return { ok: false, status: 403, error: 'Cross-origin requests are not accepted' };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { admitSameSiteRequest } from './localRequestGate.mjs';
|
||||
|
||||
const same = {
|
||||
method: 'POST',
|
||||
hostHeader: 'localhost:4173',
|
||||
protocol: 'http:',
|
||||
origin: 'http://localhost:4173',
|
||||
secFetchSite: 'same-origin',
|
||||
};
|
||||
|
||||
test('the honest same-origin browser request is admitted', () => {
|
||||
assert.equal(admitSameSiteRequest(same).ok, true);
|
||||
assert.equal(admitSameSiteRequest({ ...same, method: 'GET' }).ok, true);
|
||||
});
|
||||
|
||||
test('a cross-site Origin is refused', () => {
|
||||
const r = admitSameSiteRequest({ ...same, origin: 'https://evil.example' });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
test('an opaque Origin ("null") is refused even when Host would match', () => {
|
||||
const r = admitSameSiteRequest({ ...same, origin: 'null' });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
test('Sec-Fetch-Site cross-site without an Origin is refused (the <img> case)', () => {
|
||||
const r = admitSameSiteRequest({
|
||||
method: 'GET',
|
||||
hostHeader: 'localhost:4173',
|
||||
protocol: 'http:',
|
||||
secFetchSite: 'cross-site',
|
||||
});
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
test('Sec-Fetch-Site same-site (cross-origin sibling) is refused', () => {
|
||||
const r = admitSameSiteRequest({
|
||||
...same,
|
||||
origin: 'http://localhost:4173',
|
||||
secFetchSite: 'same-site',
|
||||
});
|
||||
assert.equal(r.ok, false, 'same-site is not same-origin');
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
test('a reverse-proxy signal header is refused', () => {
|
||||
for (const header of ['x-forwarded-for', 'forwarded', 'via', 'cf-connecting-ip', 'cf-ray', 'x-real-ip', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto']) {
|
||||
const r = admitSameSiteRequest({ ...same, proxyHeaders: { [header]: 'anything' } });
|
||||
assert.equal(r.ok, false, `${header} present → refused`);
|
||||
}
|
||||
// An empty forwarding header is not a proxy signal.
|
||||
assert.equal(admitSameSiteRequest({ ...same, proxyHeaders: { 'x-forwarded-for': '' } }).ok, true);
|
||||
});
|
||||
|
||||
test('same-origin Origin with Sec-Fetch-Site same-origin is admitted', () => {
|
||||
assert.equal(admitSameSiteRequest(same).ok, true);
|
||||
});
|
||||
|
||||
test('no Origin and no Sec-Fetch headers (curl / Node harness) is admitted', () => {
|
||||
const r = admitSameSiteRequest({
|
||||
method: 'POST',
|
||||
hostHeader: 'localhost:4173',
|
||||
protocol: 'http:',
|
||||
});
|
||||
assert.equal(r.ok, true, 'non-browser loopback tools pass');
|
||||
});
|
||||
|
||||
test('Sec-Fetch-Site none (typed URL / bookmark) is admitted', () => {
|
||||
const r = admitSameSiteRequest({
|
||||
method: 'GET',
|
||||
hostHeader: 'localhost:4173',
|
||||
protocol: 'http:',
|
||||
secFetchSite: 'none',
|
||||
});
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test('a LAN remote address is irrelevant: the function takes no remoteAddress', () => {
|
||||
// The credential-panel gate requires a loopback socket; this gate does not.
|
||||
// Passing a LAN-shaped Host/Origin pair must still match (LAN opt-in works).
|
||||
const lan = admitSameSiteRequest({
|
||||
method: 'GET',
|
||||
hostHeader: '192.168.1.5:4173',
|
||||
protocol: 'http:',
|
||||
origin: 'http://192.168.1.5:4173',
|
||||
secFetchSite: 'same-origin',
|
||||
});
|
||||
assert.equal(lan.ok, true, 'a same-origin LAN browser request is admitted');
|
||||
});
|
||||
|
||||
test('a cross-port or cross-scheme Origin against the same Host is refused', () => {
|
||||
assert.equal(admitSameSiteRequest({ ...same, origin: 'http://localhost:4174' }).ok, false, 'cross-port');
|
||||
assert.equal(admitSameSiteRequest({ ...same, origin: 'https://localhost:4173' }).ok, false, 'cross-scheme');
|
||||
assert.equal(admitSameSiteRequest({ ...same, origin: 'http://127.0.0.1:4173' }).ok, false, 'different loopback host');
|
||||
});
|
||||
|
||||
test('an unparseable Origin is refused', () => {
|
||||
assert.equal(admitSameSiteRequest({ ...same, origin: 'not a url' }).ok, false);
|
||||
});
|
||||
|
||||
test('a missing Host with a present Origin is refused (no authority to match)', () => {
|
||||
assert.equal(admitSameSiteRequest({ ...same, hostHeader: '' }).ok, false, 'empty Host → null authority');
|
||||
assert.equal(admitSameSiteRequest({ ...same, hostHeader: undefined }).ok, false, 'absent Host → null authority');
|
||||
});
|
||||
|
||||
test('a foreign Host is refused when an Origin is present', () => {
|
||||
assert.equal(admitSameSiteRequest({ ...same, hostHeader: 'evil.example:4173' }).ok, false, 'foreign Host does not match local Origin');
|
||||
});
|
||||
|
|
@ -66,6 +66,7 @@ import {
|
|||
upsertDotenvValues,
|
||||
validateKeySetupUpdates,
|
||||
} from './src/keySetupCore.mjs';
|
||||
import { admitSameSiteRequest } from './src/localRequestGate.mjs';
|
||||
import { hardenCredentialFile } from './src/keySetupHardening.mjs';
|
||||
import {
|
||||
fetchTerrainChunkWithRetry,
|
||||
|
|
@ -5054,6 +5055,33 @@ function trackBackfillProxies() {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-site request gate for the cost-bearing and log endpoints. Mirrors the
|
||||
* credential-panel `admit` helper (below) but feeds the request to the pure,
|
||||
* unit-tested `admitSameSiteRequest` in src/localRequestGate.mjs. Returns true
|
||||
* when it has already responded 403 (caller returns); false when the request is
|
||||
* admitted and the handler should continue. Refuses cross-site browser requests
|
||||
* (foreign/opaque Origin, or Sec-Fetch-Site other than same-origin/none, or
|
||||
* reverse-proxy headers) while keeping loopback non-browser tools and the LAN
|
||||
* opt-in working.
|
||||
*/
|
||||
const admitSameSite = (req, res) => {
|
||||
const verdict = admitSameSiteRequest({
|
||||
method: req.method,
|
||||
hostHeader: req.headers?.host,
|
||||
protocol: req.socket?.encrypted ? 'https:' : 'http:',
|
||||
origin: req.headers?.origin,
|
||||
secFetchSite: req.headers?.['sec-fetch-site'],
|
||||
proxyHeaders: req.headers || {},
|
||||
});
|
||||
if (verdict.ok) return false;
|
||||
res.statusCode = verdict.status;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify({ error: verdict.error }));
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Vite plugin: OpenAI Realtime ephemeral client secret.
|
||||
*
|
||||
|
|
@ -5069,6 +5097,7 @@ export function openAiRealtimeProxy() {
|
|||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
if (admitSameSite(req, res)) return;
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
const keyless = keylessHudSummaryResponse(apiKey);
|
||||
|
|
@ -5131,6 +5160,7 @@ export function openAiRealtimeProxy() {
|
|||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
if (admitSameSite(req, res)) return;
|
||||
|
||||
try {
|
||||
const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES);
|
||||
|
|
@ -5156,6 +5186,7 @@ export function openAiRealtimeProxy() {
|
|||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
if (admitSameSite(req, res)) return;
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset.
|
||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
|
|
@ -5400,6 +5431,7 @@ export function googlePlacesContextProxy() {
|
|||
res.end(JSON.stringify({ error: 'Method not allowed', places: [] }));
|
||||
return;
|
||||
}
|
||||
if (admitSameSite(req, res)) return;
|
||||
|
||||
// Keyless place context has no provider cost, so it resolves before the
|
||||
// paid-endpoint limiter can consume or exhaust quota (mirrors the HUD
|
||||
|
|
@ -7721,6 +7753,32 @@ function keySetupEndpoint() {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-Security-Policy applied to every document the dev/preview server
|
||||
* serves. Held in one constant so dev and preview cannot drift. No inline
|
||||
* script and no foreign script origin is permitted. 'unsafe-eval' is required:
|
||||
* Knockout (bundled inside @cesium/widgets) resolves the global object with
|
||||
* `(0, eval)("this")` at module load, and without it the Cesium widget never
|
||||
* initializes (verified in headless Chrome). It also covers Cesium's WASM
|
||||
* decoders. Widen any other directive only if a real violation appears.
|
||||
*/
|
||||
const LOCAL_CSP = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-eval'",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' data: https://fonts.gstatic.com",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"media-src 'self' blob: https:",
|
||||
"connect-src 'self' https: wss: ws:",
|
||||
"worker-src 'self' blob:",
|
||||
"child-src 'self' blob:",
|
||||
"manifest-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
].join('; ');
|
||||
|
||||
/**
|
||||
* Main Vite configuration factory.
|
||||
*
|
||||
|
|
@ -7781,7 +7839,15 @@ export default defineConfig(({ mode }) => {
|
|||
// serves, which is what makes that attack impossible rather than unlikely.
|
||||
headers: {
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Content-Security-Policy': "frame-ancestors 'none'",
|
||||
'Content-Security-Policy': LOCAL_CSP,
|
||||
},
|
||||
},
|
||||
// The preview server serves the same documents, so it carries the same
|
||||
// framing + CSP hardening (one LOCAL_CSP constant, no drift).
|
||||
preview: {
|
||||
headers: {
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Content-Security-Policy': LOCAL_CSP,
|
||||
},
|
||||
},
|
||||
// Expose selected API keys to the browser via import.meta.env.*
|
||||
|
|
|
|||
Loading…
Reference in New Issue