From 22545e46bb5a1ace260f71b06c926372d323ac8a Mon Sep 17 00:00:00 2001 From: Alexandre Tuleu Date: Fri, 11 Aug 2023 13:52:10 +0200 Subject: [PATCH] Made infrastructure to ensure private data is not leaked to client --- package-lock.json | 42 +++++++++++++++ package.json | 1 + src/config.yml | 15 +++++- src/hooks.server.ts | 24 +++++++++ src/lib/config.test.ts | 58 ++++++++++++++++++++ src/lib/config.ts | 105 +++++++++++++++++++++++++++++++------ src/routes/+page.server.ts | 16 ++---- 7 files changed, 232 insertions(+), 29 deletions(-) mode change 120000 => 100644 src/config.yml create mode 100644 src/hooks.server.ts create mode 100644 src/lib/config.test.ts diff --git a/package-lock.json b/package-lock.json index 214f5c0..a236a9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@sveltejs/kit": "^1.20.4", "@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/parser": "^5.45.0", + "@vitest/ui": "^0.34.1", "eslint": "^8.28.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-svelte": "^2.30.0", @@ -1223,6 +1224,41 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/ui": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-0.34.1.tgz", + "integrity": "sha512-bwmkgMjDcMr3pg0UXLwfwZ/WI1fq2N+5DUisqHkY9bvnNRnpT6QiewtSS/VhmN61ixgNpSKbEGVboml2GLuxfA==", + "dev": true, + "dependencies": { + "@vitest/utils": "0.34.1", + "fast-glob": "^3.3.0", + "fflate": "^0.8.0", + "flatted": "^3.2.7", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "sirv": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": ">=0.30.1 <1" + } + }, + "node_modules/@vitest/ui/node_modules/@vitest/utils": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.1.tgz", + "integrity": "sha512-/ql9dsFi4iuEbiNcjNHQWXBum7aL8pyhxvfnD9gNtbjR9fUKAjxhj4AA3yfLXg6gJpMGGecvtF8Au2G9y3q47Q==", + "dev": true, + "dependencies": { + "diff-sequences": "^29.4.3", + "loupe": "^2.3.6", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/utils": { "version": "0.32.4", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.32.4.tgz", @@ -2086,6 +2122,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.0.tgz", + "integrity": "sha512-FAdS4qMuFjsJj6XHbBaZeXOgaypXp8iw/Tpyuq/w3XA41jjLHT8NPA+n7czH/DDhdncq0nAyDZmPeWXh2qmdIg==", + "dev": true + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", diff --git a/package.json b/package.json index 24c52c2..d0df873 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@sveltejs/kit": "^1.20.4", "@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/parser": "^5.45.0", + "@vitest/ui": "^0.34.1", "eslint": "^8.28.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-svelte": "^2.30.0", diff --git a/src/config.yml b/src/config.yml deleted file mode 120000 index 1a3631f..0000000 --- a/src/config.yml +++ /dev/null @@ -1 +0,0 @@ -../static/config.yml \ No newline at end of file diff --git a/src/config.yml b/src/config.yml new file mode 100644 index 0000000..de9dbac --- /dev/null +++ b/src/config.yml @@ -0,0 +1,14 @@ +title: 'Hello World !!' +subtitle: 'actually, I am a new pilot.' + +services: + - title: '/Cloud' + subtitle: 'Private Cloud Utilities' + icon: 'fas fa-cloud' + items: + - title: 'NAS' + subtitle: 'Network Attached Storage' + icon: 'fas fa-hard-drive' + target: '_blank' + url: '/NAS' + keywords: 'cloud storage files' diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..0aa0072 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,24 @@ +import { dev } from "$app/environment"; +import { clientConfig, stripPrivateFields, type Config } from "$lib/config"; +import type { Handle } from "@sveltejs/kit"; +import { readFile } from 'fs'; +import * as yml from 'js-yaml'; + +async function reloadConfig(): Promise { + try { + const dynamic = yml.load(await readFile('/dynamic/config.yml', 'utf8')); + return stripPrivateFields({ ...clientConfig, ...dynamic }); + } catch (err) { + return clientConfig; + } + +} + +export const handle: Handle = async ({ event, resolve }) => { + if ( dev == false ) { + Object.assign(event.locals,{config: await reloadConfig()}); + } + + const response = await resolve(event); + return response; +} diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts new file mode 100644 index 0000000..b9e4617 --- /dev/null +++ b/src/lib/config.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { config, defaultConfig, mergeConfig, type Config, stripPrivateFields } from './config'; + +describe('Config', () => { + it('should be export a build time config', () => { + expect(config).toBeTruthy(); + }); + + it('should be able to merge with POJO', () => { + const merged = mergeConfig(defaultConfig, { + subtitle: "Homer's favorite neighboor", + services: [ + { + title: 'favorite occupations', + items: [ + { + title: 'anoy homer', + customKey: 'all the time' + } + ] + } + ] + }); + + expect(merged.title).toEqual('Flanders'); + expect(merged.services).toHaveLength(1); + expect(merged.services[0].title).toEqual('favorite occupations'); + expect(merged.services[0].items).toHaveLength(1); + expect(merged.services[0].items[0].title).toEqual('anoy homer'); + expect(merged.services[0].items[0].customKey).toEqual('all the time'); + }); + + it('should be able to strip custom keys', () => { + const custom: Config = { + title: 'custom', + secret: {secret: 'some secret'}, + services: [ + { + title: 'top services', + secret: 'secret', + items: [ + { + title: 'top service', + url: 'somewhere', + secret: 'secret' + } + ] + } + ] + }; + + const stripped: Config = stripPrivateFields(custom); + + expect(stripped.secret).toBeUndefined(); + expect(stripped.services[0].secret).toBeUndefined(); + expect(stripped.services[0].items[0].secret).toBeUndefined(); + }); +}); diff --git a/src/lib/config.ts b/src/lib/config.ts index 0556740..7ab527d 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,36 +1,109 @@ import configData from '../config.yml'; -export type Brand = { +export interface Brand { logo?: string; icon?: string; usemask?: boolean; -}; +} -export type Section = { +export interface Section extends Brand { title: string; subtitle?: string; -} & Brand; +} -export type Service = { +export interface Service extends Section { url: string; target?: string; type?: string; [x: string]: unknown; -} & Section; +} -export type ServiceGroup = { +export interface ServiceGroup extends Section { items: Service[]; -} & Section; -export type Config = { + [x: string]: unknown; +} + +export interface Config extends Section { services: ServiceGroup[]; -} & Section; -export const config: Config = { - ...{ - title: 'Flanders', - services: [] - }, - ...configData + [x: string]: unknown; +} + +type DeepRequired = T extends object + ? { + [P in keyof T]: DeepRequired; + } + : T; + +const requiredConfig: DeepRequired = { + title: '', + subtitle: '', + services: [ + { + title: '', + items: [ + { + title: '', + url: '', + } + ], + } + ] }; + +export function mergeConfig(a: Config, b: any): Config { + return { ...a, ...b }; +} + +export const defaultConfig: Config = { + title: 'Flanders', + + services: [] +}; + + +type SPOJO = Record + +function strip (toStrip: Type, reference: Type): Type { + const res: Type = {...toStrip} + const referenceNames = Object.entries(reference).map(([key,value]) => key) + + for ( const [key,value] of Object.entries(res) ) { + if ( referenceNames.includes(key) == false ) { + // remove the object + delete res[key]; + continue + } + + // strips further arrays + if ( value instanceof Array ) { + const stripped : SPOJO = {}; + const childRef = (reference[key] as Array)[0]; + stripped[key] = value.map((v: SPOJO) => strip(v,childRef)); + Object.assign(res,stripped); + continue; + } + + if ( typeof value != "object") { + continue + } + + // it is a child object, we strip it further + const stripped : SPOJO = {}; + stripped[key] = strip(value as SPOJO,reference[key] as SPOJO); + Object.assign(res,stripped); + + } + return res; +} + +export function stripPrivateFields(config: Config): Config { + return strip(config,requiredConfig); +} + + +export const config: Config = mergeConfig(defaultConfig, configData); + +export const clientConfig :Config = stripPrivateFields(config) diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index d02d9cc..d5a9976 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,20 +1,12 @@ import { dev } from '$app/environment'; -import { config, type Config } from '$lib/config'; +import { clientConfig, type Config } from '$lib/config'; import type { PageServerLoad } from './$types'; -import { readFileSync } from 'fs'; -import * as yml from 'js-yaml'; -export const load: PageServerLoad = () => { +export const load: PageServerLoad = ({locals}) => { if (dev) { - return { config: config }; + return { config: clientConfig }; } - try { - const dynamic = yml.load(readFileSync('/dynamic/config.yml', 'utf8')); - return { config: { ...config, ...dynamic } as Config }; - } catch (err) { - console.debug("could not read '/dynamic/config.yml': " + err); - return { config: config }; - } + return {config: (locals as Record).config || clientConfig}; };