mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-14 17:42:17 +00:00
fix(export): restrict provider endpoint overrides to loopback test servers
The endpoint overrides are read through `$env/dynamic/private`, so they are evaluated per request in production, not baked in at build time. That made a single injected environment variable enough to redirect the authorization-code and refresh-token POSTs - which carry the OAuth client secret and the user's refresh token - to an arbitrary host, and to redirect the user's authorize hop to an arbitrary URL. Overrides are now ignored unless ALLOW_PROVIDER_ENDPOINT_OVERRIDES is exactly "true" and the value is a loopback URL. `npm run test:bdd` sets the flag; nothing else should. resolveProviderEndpoints is pure so the refusals are unit tested, including near-miss hosts such as http://127.0.0.1.example. Also drops the unused `pokedex` parameter from buildCsv rather than silencing it with `void`, and the dead hasGigantamaxed field from its fallback record.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Stands in for `$env/dynamic/private` so modules that reach for runtime env can be unit
|
||||
* tested. Vitest maps the virtual module here; see vitest.config.mts.
|
||||
*/
|
||||
export const env: Record<string, string | undefined> = process.env;
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
csvEscape,
|
||||
sanitizeFileName,
|
||||
shouldRefreshToken
|
||||
} from '../../src/lib/services/PokedexExportFormatting';
|
||||
} from '$lib/services/PokedexExportFormatting';
|
||||
|
||||
describe('Pokédex export formatting', () => {
|
||||
it.each([
|
||||
@@ -26,35 +26,32 @@ describe('Pokédex export formatting', () => {
|
||||
});
|
||||
|
||||
it('builds a stable, escaped CSV with defaults for missing catch records', () => {
|
||||
const csv = buildCsv(
|
||||
{ _id: 'dex-1', name: 'Test' } as never,
|
||||
[
|
||||
{
|
||||
pokedexEntry: {
|
||||
_id: '25',
|
||||
pokedexNumber: 25,
|
||||
pokemon: 'Pikachu',
|
||||
form: null
|
||||
},
|
||||
catchRecord: {
|
||||
caught: true,
|
||||
haveToEvolve: false,
|
||||
inHome: true,
|
||||
hasGigantamaxed: false,
|
||||
personalNotes: 'Comma, and "quote"'
|
||||
}
|
||||
const csv = buildCsv([
|
||||
{
|
||||
pokedexEntry: {
|
||||
_id: '25',
|
||||
pokedexNumber: 25,
|
||||
pokemon: 'Pikachu',
|
||||
form: null
|
||||
},
|
||||
{
|
||||
pokedexEntry: {
|
||||
_id: '26',
|
||||
pokedexNumber: 26,
|
||||
pokemon: 'Raichu',
|
||||
form: 'Alolan'
|
||||
},
|
||||
catchRecord: null
|
||||
catchRecord: {
|
||||
caught: true,
|
||||
haveToEvolve: false,
|
||||
inHome: true,
|
||||
hasGigantamaxed: false,
|
||||
personalNotes: 'Comma, and "quote"'
|
||||
}
|
||||
] as never
|
||||
);
|
||||
},
|
||||
{
|
||||
pokedexEntry: {
|
||||
_id: '26',
|
||||
pokedexNumber: 26,
|
||||
pokemon: 'Raichu',
|
||||
form: 'Alolan'
|
||||
},
|
||||
catchRecord: null
|
||||
}
|
||||
] as never);
|
||||
expect(csv.split('\r\n')).toEqual([
|
||||
'pokemonId,pokedexNumber,pokemon,form,caught,haveToEvolve,inHome,personalNotes',
|
||||
'25,25,Pikachu,,true,false,true,"Comma, and ""quote"""',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PROVIDER_ENDPOINT_DEFAULTS,
|
||||
resolveProviderEndpoints
|
||||
} from '$lib/services/providerEndpoints';
|
||||
|
||||
const localOverrides = {
|
||||
GOOGLE_OAUTH_AUTHORIZE_URL: 'http://127.0.0.1:4199/google/authorize',
|
||||
GOOGLE_OAUTH_TOKEN_URL: 'http://127.0.0.1:4199/google/token',
|
||||
GOOGLE_DRIVE_API_URL: 'http://127.0.0.1:4199/google/drive',
|
||||
GOOGLE_DRIVE_UPLOAD_URL: 'http://127.0.0.1:4199/google/upload',
|
||||
DROPBOX_OAUTH_AUTHORIZE_URL: 'http://127.0.0.1:4199/dropbox/authorize',
|
||||
DROPBOX_OAUTH_TOKEN_URL: 'http://127.0.0.1:4199/dropbox/token',
|
||||
DROPBOX_UPLOAD_URL: 'http://127.0.0.1:4199/dropbox/files/upload'
|
||||
};
|
||||
|
||||
describe('provider endpoints', () => {
|
||||
it('uses the real provider endpoints when nothing is configured', () => {
|
||||
expect(resolveProviderEndpoints({})).toEqual(PROVIDER_ENDPOINT_DEFAULTS);
|
||||
});
|
||||
|
||||
it('ignores overrides unless they are explicitly allowed', () => {
|
||||
// The exfiltration case: these variables carry the client secret and refresh token, and
|
||||
// the env is read per request in production.
|
||||
expect(resolveProviderEndpoints(localOverrides)).toEqual(PROVIDER_ENDPOINT_DEFAULTS);
|
||||
expect(
|
||||
resolveProviderEndpoints({ ...localOverrides, ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'false' })
|
||||
).toEqual(PROVIDER_ENDPOINT_DEFAULTS);
|
||||
expect(
|
||||
resolveProviderEndpoints({ ...localOverrides, ALLOW_PROVIDER_ENDPOINT_OVERRIDES: '1' })
|
||||
).toEqual(PROVIDER_ENDPOINT_DEFAULTS);
|
||||
});
|
||||
|
||||
it('applies allowed loopback overrides', () => {
|
||||
expect(
|
||||
resolveProviderEndpoints({ ...localOverrides, ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true' })
|
||||
).toEqual({
|
||||
google: {
|
||||
authorize: localOverrides.GOOGLE_OAUTH_AUTHORIZE_URL,
|
||||
token: localOverrides.GOOGLE_OAUTH_TOKEN_URL,
|
||||
driveApi: localOverrides.GOOGLE_DRIVE_API_URL,
|
||||
driveUpload: localOverrides.GOOGLE_DRIVE_UPLOAD_URL
|
||||
},
|
||||
dropbox: {
|
||||
authorize: localOverrides.DROPBOX_OAUTH_AUTHORIZE_URL,
|
||||
token: localOverrides.DROPBOX_OAUTH_TOKEN_URL,
|
||||
upload: localOverrides.DROPBOX_UPLOAD_URL
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://attacker.example/token',
|
||||
'http://127.0.0.1.attacker.example/token',
|
||||
'http://[::2]/token',
|
||||
'file:///etc/passwd',
|
||||
'not-a-url',
|
||||
''
|
||||
])('refuses the non-loopback override %j even when overrides are allowed', (value) => {
|
||||
const endpoints = resolveProviderEndpoints({
|
||||
ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true',
|
||||
GOOGLE_OAUTH_TOKEN_URL: value,
|
||||
DROPBOX_OAUTH_TOKEN_URL: value
|
||||
});
|
||||
expect(endpoints.google.token).toBe(PROVIDER_ENDPOINT_DEFAULTS.google.token);
|
||||
expect(endpoints.dropbox.token).toBe(PROVIDER_ENDPOINT_DEFAULTS.dropbox.token);
|
||||
});
|
||||
|
||||
it('accepts localhost as well as 127.0.0.1', () => {
|
||||
expect(
|
||||
resolveProviderEndpoints({
|
||||
ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true',
|
||||
GOOGLE_OAUTH_TOKEN_URL: 'http://localhost:4199/google/token'
|
||||
}).google.token
|
||||
).toBe('http://localhost:4199/google/token');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user