diff --git a/Uebersicht/UBAppDelegate.h b/Uebersicht/UBAppDelegate.h index e50f6323..ce0e1f2a 100644 --- a/Uebersicht/UBAppDelegate.h +++ b/Uebersicht/UBAppDelegate.h @@ -20,6 +20,7 @@ - (void)widgetDirDidChange; - (void)interactionDidChange; +- (void)enableSecurityDidChange; - (void)screensChanged:(NSDictionary*)screens; - (IBAction)showPreferences:(id)sender; - (IBAction)openWidgetDir:(id)sender; diff --git a/Uebersicht/UBAppDelegate.m b/Uebersicht/UBAppDelegate.m index 5a6b3739..199bbf9d 100644 --- a/Uebersicht/UBAppDelegate.m +++ b/Uebersicht/UBAppDelegate.m @@ -33,10 +33,13 @@ @implementation UBAppDelegate { UBWidgetsStore* widgetsStore; UBWidgetsController* widgetsController; BOOL needsRefresh; + NSString *token; } @synthesize statusBarMenu; +static const uint kTokenLength256Bits = 32; + - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { needsRefresh = YES; @@ -110,33 +113,58 @@ - (void)applicationDidFinishLaunching:(NSNotification *)aNotification [self listenToWallpaperChanges]; } -- (NSDictionary*)fetchState +- (void)fetchState:(void (^)(NSDictionary*))callback { - [[UBWebSocket sharedSocket] open:[self serverUrl:@"ws"]]; + [[UBWebSocket sharedSocket] open:[self serverUrl:@"ws"] + token:token]; + NSURL *urlPath = [[self serverUrl:@"http"] URLByAppendingPathComponent: @"state/"]; - NSData *jsonData = [NSData dataWithContentsOfURL:urlPath]; - NSError *error = nil; - NSDictionary *dataDictionary = [NSJSONSerialization - JSONObjectWithData: jsonData - options: NSJSONReadingMutableContainers - error: &error - ]; - if (error) NSLog(@"%@", error); - return dataDictionary; + NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:urlPath]; + [request setValue:@"Übersicht" forHTTPHeaderField:@"Origin"]; + [request setValue:[NSString stringWithFormat:@"token=%@", token] forHTTPHeaderField:@"Cookie"]; + NSURLSessionDataTask *t = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) { + NSLog(@"error loading state: %@", error); + dispatch_async(dispatch_get_main_queue(), ^{ + callback(@{}); + }); + return; + } + + NSError *jsonError = nil; + NSDictionary *dataDictionary = [NSJSONSerialization + JSONObjectWithData: data + options: NSJSONReadingMutableContainers + error: &jsonError + ]; + if (jsonError) { + NSLog(@"error parsing state: %@", error); + dispatch_async(dispatch_get_main_queue(), ^{ + callback(@{}); + }); + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + callback(dataDictionary); + }); + }]; + [t resume]; } - (void)startUp { NSLog(@"starting server task"); - + void (^handleData)(NSString*) = ^(NSString* output) { // note that these might be called several times if ([output rangeOfString:@"server started"].location != NSNotFound) { - [self->widgetsStore reset: [self fetchState]]; - // this will trigger a render - [self->screensController syncScreens:self]; - + [self fetchState:^(NSDictionary* state) { + [self->widgetsStore reset: state]; + // this will trigger a render + [self->screensController syncScreens:self]; + }]; } else if ([output rangeOfString:@"EADDRINUSE"].location != NSNotFound) { self->portOffset++; } @@ -216,15 +244,28 @@ - (NSTask*)launchWidgetServer:(NSString*)widgetPath onData:(void (^)(NSString*))dataHandler onExit:(void (^)(NSTask*))exitHandler { + token = generateToken(kTokenLength256Bits); + NSBundle* bundle = [NSBundle mainBundle]; NSString* nodePath = [bundle pathForResource:@"localnode" ofType:nil]; NSString* serverPath = [bundle pathForResource:@"server" ofType:@"js"]; - BOOL loginShell = [[NSUserDefaults standardUserDefaults] - boolForKey:@"loginShell" - ]; NSTask *task = [[NSTask alloc] init]; + NSPipe *inPipe = [NSPipe pipe]; + NSFileHandle *fh = [inPipe fileHandleForWriting]; + NSMutableDictionary *secrets = [[NSMutableDictionary alloc] init]; + secrets[@"token"] = token; + NSError *jsonErr = NULL; + NSData *stdinData = [NSJSONSerialization dataWithJSONObject:secrets options:0 error:&jsonErr]; + if (!stdinData) { + NSLog(@"[FATAL] %@", jsonErr); + return NULL; + } + [fh writeData:stdinData]; + [fh closeFile]; + [task setStandardInput:inPipe]; + [task setStandardOutput:[NSPipe pipe]]; [task.standardOutput fileHandleForReading].readabilityHandler = ^(NSFileHandle *handle) { NSData *output = [handle availableData]; @@ -252,7 +293,8 @@ - (NSTask*)launchWidgetServer:(NSString*)widgetPath @"-d", widgetPath, @"-p", [NSString stringWithFormat:@"%d", PORT + portOffset], @"-s", [[self getPreferencesDir] path], - loginShell ? @"--login-shell" : @"" + !preferences.enableSecurity ? @"--disable-token" : @"", + preferences.loginShell ? @"--login-shell" : @"" ]]; [task launch]; @@ -294,6 +336,7 @@ - (void)screensChanged:(NSDictionary*)screens [windowsController updateWindows:screens baseUrl: [self serverUrl: @"http"] + token:token interactionEnabled: preferences.enableInteraction forceRefresh: needsRefresh ]; @@ -323,6 +366,11 @@ - (void)interactionDidChange [screensController syncScreens:self]; } +- (void)enableSecurityDidChange +{ + [self shutdown:true]; +} + - (IBAction)showPreferences:(id)sender { [preferences showWindow:nil]; @@ -453,6 +501,29 @@ void wallpaperSettingsChanged( } } +/*! + @function generateToken + + @abstract + Returns a base64-encoded @p NSString* of specified number of random bytes. + + @param length + A reasonably large, non-zero number representing the length in bytes. + For example, a value of 32 would generate a 256-bit token. + + @result Returns @p NSString* on success; panics on failure. +*/ +NSString* generateToken(uint length) { + UInt8 buf[length]; + + int error = SecRandomCopyBytes(kSecRandomDefault, length, &buf); + if (error != errSecSuccess) { + panic("failed to generate token"); + } + + return [[NSData dataWithBytes:buf length:length] base64EncodedStringWithOptions:0]; +} + # # pragma mark script support # diff --git a/Uebersicht/UBPreferencesController.h b/Uebersicht/UBPreferencesController.h index 7ac1e1fb..7f569d07 100644 --- a/Uebersicht/UBPreferencesController.h +++ b/Uebersicht/UBPreferencesController.h @@ -20,6 +20,7 @@ @property NSURL* widgetDir; @property BOOL loginShell; @property BOOL enableInteraction; +@property BOOL enableSecurity; - (IBAction)showFilePicker:(id)sender; diff --git a/Uebersicht/UBPreferencesController.m b/Uebersicht/UBPreferencesController.m index ec696d8b..aa8e054f 100644 --- a/Uebersicht/UBPreferencesController.m +++ b/Uebersicht/UBPreferencesController.m @@ -16,6 +16,8 @@ @implementation UBPreferencesController { LSSharedFileListRef loginItems; } +static NSString * const kDefaultsEnableSecurity = @"enableSecurity"; + @synthesize filePicker; - (id)initWithWindowNibName:(NSString *)windowNibName @@ -26,7 +28,8 @@ - (id)initWithWindowNibName:(NSString *)windowNibName NSData* defaultWidgetDir = [self ensureDefaultsWidgetDir]; NSDictionary *appDefaults = @{ @"widgetDirectory": defaultWidgetDir, - @"enableInteraction": @YES + @"enableInteraction": @YES, + kDefaultsEnableSecurity: @YES, }; [[NSUserDefaults standardUserDefaults] registerDefaults:appDefaults]; @@ -196,6 +199,23 @@ - (void)setEnableInteraction:(BOOL)enabled [(UBAppDelegate *)[NSApp delegate] interactionDidChange]; } +# +#pragma mark Security +# + +- (BOOL)enableSecurity +{ + NSUserDefaults* defaults = [NSUserDefaults standardUserDefaults]; + return [defaults boolForKey:kDefaultsEnableSecurity]; +} + +- (void)setEnableSecurity:(BOOL)enabled +{ + NSUserDefaults* defaults = [NSUserDefaults standardUserDefaults]; + [defaults setBool:enabled forKey:kDefaultsEnableSecurity]; + [(UBAppDelegate *)[NSApp delegate] enableSecurityDidChange]; +} + # #pragma mark Startup # diff --git a/Uebersicht/UBPreferencesController.xib b/Uebersicht/UBPreferencesController.xib index 5674caa0..08d38f9b 100644 --- a/Uebersicht/UBPreferencesController.xib +++ b/Uebersicht/UBPreferencesController.xib @@ -17,12 +17,12 @@ - + - - + + - + @@ -124,6 +124,15 @@ + + + + + + + + + @@ -133,6 +142,26 @@ + + + + + + + + + + diff --git a/Uebersicht/UBWebSocket.h b/Uebersicht/UBWebSocket.h index 8918d1a0..57fc51f7 100644 --- a/Uebersicht/UBWebSocket.h +++ b/Uebersicht/UBWebSocket.h @@ -12,7 +12,8 @@ @interface UBWebSocket : NSObject + (id)sharedSocket; -- (void)open:(NSURL*)aUrl; +- (void)open:(NSURL*)aUrl + token:(NSString*)aToken; - (void)close; - (void)send:(id)message; - (void)listen:(void (^)(id))listener; diff --git a/Uebersicht/UBWebSocket.m b/Uebersicht/UBWebSocket.m index 84accb50..feb92103 100644 --- a/Uebersicht/UBWebSocket.m +++ b/Uebersicht/UBWebSocket.m @@ -13,6 +13,7 @@ @implementation UBWebSocket { NSMutableArray* queuedMessages; SRWebSocket* ws; NSURL* url; + NSString *token; } @@ -49,16 +50,26 @@ - (void)listen:(void (^)(id))listener } - (void)open:(NSURL*)aUrl + token:(NSString*)aToken { if (ws) { return; } + token = aToken; url = aUrl; NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; [request setValue:@"Übersicht" forHTTPHeaderField:@"Origin"]; ws = [[SRWebSocket alloc] initWithURLRequest: request]; ws.delegate = self; + ws.requestCookies = @[ + [NSHTTPCookie cookieWithProperties:@{ + NSHTTPCookieDomain: url.host, + NSHTTPCookiePath: @"/", + NSHTTPCookieName: @"token", + NSHTTPCookieValue: token, + }], + ]; [ws open]; } @@ -76,7 +87,8 @@ - (void)reopen { [self close]; if (url) { - [self open:url]; + [self open:url + token:token]; } } diff --git a/Uebersicht/UBWebViewController.h b/Uebersicht/UBWebViewController.h index 6a1cd4b7..2e82b381 100644 --- a/Uebersicht/UBWebViewController.h +++ b/Uebersicht/UBWebViewController.h @@ -15,6 +15,7 @@ - (id)initWithFrame:(NSRect)frame; - (void)load:(NSURL*)url; +- (void)setToken:(NSString*)token; - (void)reload; - (void)redraw; - (void)destroy; diff --git a/Uebersicht/UBWebViewController.m b/Uebersicht/UBWebViewController.m index e22f25f9..c1c18bd8 100644 --- a/Uebersicht/UBWebViewController.m +++ b/Uebersicht/UBWebViewController.m @@ -14,6 +14,7 @@ @implementation UBWebViewController { NSURL* url; + NSString *token; } @synthesize view; @@ -44,7 +45,14 @@ - (void)load:(NSURL*)newUrl default: break; } - [(WKWebView*)view loadRequest:[NSURLRequest requestWithURL: url]]; + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; + [request setValue:@"Übersicht" forHTTPHeaderField:@"Origin"]; + [(WKWebView*)view loadRequest:request]; +} + +- (void)setToken:(NSString*)newToken +{ + token = newToken; } - (void)reload @@ -173,7 +181,6 @@ - (void)webView:(WKWebView *)sender [self handleWebviewLoadError:error]; } - - (void)webView: (WKWebView *)theWebView decidePolicyForNavigationAction: (WKNavigationAction*)action decisionHandler: (void (^)(WKNavigationActionPolicy))handler @@ -181,7 +188,16 @@ - (void)webView: (WKWebView *)theWebView if (!action.targetFrame.mainFrame) { handler(WKNavigationActionPolicyAllow); } else if ([action.request.URL isEqual: url]) { - handler(WKNavigationActionPolicyAllow); + NSHTTPCookie *c = [NSHTTPCookie cookieWithProperties:@{ + NSHTTPCookieDomain: url.host, + NSHTTPCookiePath: @"/", + NSHTTPCookieName: @"token", + NSHTTPCookieValue: token, + @"HttpOnly": @"TRUE", + }]; + [theWebView.configuration.websiteDataStore.httpCookieStore setCookie:c completionHandler:^{ + handler(WKNavigationActionPolicyAllow); + }]; } else if (action.navigationType == WKNavigationTypeLinkActivated) { [[NSWorkspace sharedWorkspace] openURL:action.request.URL]; handler(WKNavigationActionPolicyCancel); diff --git a/Uebersicht/UBWindow.h b/Uebersicht/UBWindow.h index 63fe987d..c49704df 100644 --- a/Uebersicht/UBWindow.h +++ b/Uebersicht/UBWindow.h @@ -26,6 +26,7 @@ typedef NS_ENUM(NSInteger, UBWindowType) { - (id)initWithWindowType:(UBWindowType)type; - (void)loadUrl:(NSURL*)url; +- (void)setToken:(NSString*)token; - (void)reload; - (void)workspaceChanged; - (void)wallpaperChanged; diff --git a/Uebersicht/UBWindow.m b/Uebersicht/UBWindow.m index 39885039..94031c6d 100644 --- a/Uebersicht/UBWindow.m +++ b/Uebersicht/UBWindow.m @@ -62,6 +62,11 @@ - (void)loadUrl:(NSURL*)url [webViewController load:url]; } +- (void)setToken:(NSString*)token +{ + [webViewController setToken:token]; +} + - (void)reload { [webViewController reload]; diff --git a/Uebersicht/UBWindowGroup.h b/Uebersicht/UBWindowGroup.h index be130dd0..d63e3cf4 100644 --- a/Uebersicht/UBWindowGroup.h +++ b/Uebersicht/UBWindowGroup.h @@ -18,6 +18,7 @@ NS_ASSUME_NONNULL_BEGIN - (id)initWithInteractionEnabled:(BOOL)interactionEnabled; - (void)loadUrl:(NSURL*)Url; +- (void)setToken:(NSString*)token; - (void)reload; - (void)close; - (void)setFrame:(NSRect)frame display:(BOOL)flag; diff --git a/Uebersicht/UBWindowGroup.m b/Uebersicht/UBWindowGroup.m index 183de98c..2460146d 100644 --- a/Uebersicht/UBWindowGroup.m +++ b/Uebersicht/UBWindowGroup.m @@ -54,6 +54,12 @@ - (void)loadUrl:(NSURL*)url [background loadUrl: url]; } +- (void)setToken:(NSString*)token +{ + [foreground setToken:token]; + [background setToken:token]; +} + - (void)setFrame:(NSRect)frame display:(BOOL)flag { [foreground setFrame:frame display:flag]; diff --git a/Uebersicht/UBWindowsController.h b/Uebersicht/UBWindowsController.h index b5831b15..c82ee034 100644 --- a/Uebersicht/UBWindowsController.h +++ b/Uebersicht/UBWindowsController.h @@ -15,6 +15,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)updateWindows:(NSDictionary*)screens baseUrl:(NSURL*)baseUrl + token:(NSString*)token interactionEnabled:(Boolean)interactionEnabled forceRefresh:(Boolean)forceRefresh; diff --git a/Uebersicht/UBWindowsController.m b/Uebersicht/UBWindowsController.m index 7e1e8ec8..bcee5857 100644 --- a/Uebersicht/UBWindowsController.m +++ b/Uebersicht/UBWindowsController.m @@ -31,6 +31,7 @@ - (id)init - (void)updateWindows:(NSDictionary*)screens baseUrl:(NSURL*)baseUrl + token:(NSString*)token interactionEnabled:(Boolean)interactionEnabled forceRefresh:(Boolean)forceRefresh { @@ -43,6 +44,7 @@ - (void)updateWindows:(NSDictionary*)screens initWithInteractionEnabled: interactionEnabled ]; [windows setObject:windowGroup forKey:screenId]; + [windowGroup setToken:token]; [windowGroup loadUrl: [self screenUrl:screenId baseUrl:baseUrl]]; } else { windowGroup = windows[screenId]; diff --git a/server/server.coffee b/server/server.coffee index 8c30c369..d862e128 100644 --- a/server/server.coffee +++ b/server/server.coffee @@ -2,25 +2,31 @@ parseArgs = require 'minimist' UebersichtServer = require './src/app.coffee' cors_proxy = require 'cors-anywhere' path = require 'path' +fs = require 'fs' +crypto = require 'node:crypto' handleError = (err) -> console.log(err.message || err) throw err try + secrets = JSON.parse fs.readFileSync(process.stdin.fd, 'utf-8') args = parseArgs process.argv.slice(2) widgetPath = path.resolve(__dirname, args.d ? args.dir ? './widgets') port = args.p ? args.port ? 41416 + token = secrets.token ? crypto.randomUUID() settingsPath = path.resolve(__dirname, args.s ? args.settings ? './settings') publicPath = path.resolve(__dirname, './public') options = loginShell: args['login-shell'] + disableToken: args['disable-token'] server = UebersichtServer( Number(port), widgetPath, settingsPath, publicPath, + token, options, -> console.log 'server started on port', port ) diff --git a/server/src/SharedSocket.js b/server/src/SharedSocket.js index 1028919f..598adff1 100644 --- a/server/src/SharedSocket.js +++ b/server/src/SharedSocket.js @@ -27,8 +27,8 @@ function handleError(err) { console.error(err); } -exports.open = function open(url) { - ws = new WebSocket(url, ['ws'], {origin: 'Übersicht'}); +exports.open = function open(url, token) { + ws = new WebSocket(url, ['ws'], {origin: 'Übersicht', headers:{cookie:`token=${token}`}}); if (ws.on) { ws.on('open', handleWSOpen); @@ -63,4 +63,3 @@ exports.onOpen = function onOpen(listener) { exports.send = function send(data) { ws.send(data); }; - diff --git a/server/src/app.coffee b/server/src/app.coffee index 5f8080ad..7efc9352 100644 --- a/server/src/app.coffee +++ b/server/src/app.coffee @@ -11,6 +11,8 @@ WidgetBundler = require('./WidgetBundler.js') Settings = require('./Settings') StateServer = require('./StateServer') ensureSameOrigin = require('./ensureSameOrigin') +ensureToken = require('./ensureToken') +validateTokenCookie = require('./validateTokenCookie') disallowIFraming = require('./disallowIFraming') CommandServer = require('./command_server.coffee') serveWidgets = require('./serveWidgets') @@ -24,7 +26,7 @@ resolveWidget = require('./resolveWidget') dispatchToRemote = require('./dispatch') listenToRemote = require('./listen') -module.exports = (port, widgetPath, settingsPath, publicPath, options, callback) -> +module.exports = (port, widgetPath, settingsPath, publicPath, token, options, callback) -> options ||= {} # global store for app state @@ -74,6 +76,7 @@ module.exports = (port, widgetPath, settingsPath, publicPath, options, callback) middleware = connect() .use(disallowIFraming) .use(ensureSameOrigin(allowedOrigin)) + .use(ensureToken(token, options.disableToken)) .use(CommandServer(widgetPath, options.loginShell)) .use(StateServer(store)) .use(serveWidgets(bundler, widgetPath)) @@ -90,9 +93,12 @@ module.exports = (port, widgetPath, settingsPath, publicPath, options, callback) messageBus = MessageBus( server: server, verifyClient: (info) -> - info.origin == allowedOrigin || info.origin == 'Übersicht' + originOkay = info.origin == allowedOrigin || info.origin == 'Übersicht' + if options.disableToken + return originOkay + originOkay && validateTokenCookie(token, info.req.headers.cookie) ) - sharedSocket.open("ws://#{host}:#{port}") + sharedSocket.open("ws://#{host}:#{port}", token) callback?() catch e server.emit('error', e) diff --git a/server/src/ensureSameOrigin.js b/server/src/ensureSameOrigin.js index 07cd9e94..2869fde0 100644 --- a/server/src/ensureSameOrigin.js +++ b/server/src/ensureSameOrigin.js @@ -1,6 +1,6 @@ module.exports = function ensureSameOrgin(origin) { const fromSameOrigin = (req) => { - return req.method == 'GET' || + return req.method === 'GET' || (req.headers.origin && req.headers.origin === origin); } diff --git a/server/src/ensureToken.js b/server/src/ensureToken.js new file mode 100644 index 00000000..3b416354 --- /dev/null +++ b/server/src/ensureToken.js @@ -0,0 +1,17 @@ +var validateTokenCookie = require('./validateTokenCookie') + +module.exports = function ensureToken(token, disabled) { + return ((req, res, next) => { + if (disabled) { + return next() + } + + if (!validateTokenCookie(token, req.headers.cookie)) { + res.writeHead(403) + res.end() + return + } + + return next() + }) +} diff --git a/server/src/validateTokenCookie.js b/server/src/validateTokenCookie.js new file mode 100644 index 00000000..895a4773 --- /dev/null +++ b/server/src/validateTokenCookie.js @@ -0,0 +1,19 @@ +module.exports = function validateTokenCookie(token, cookieStr) { + if (!cookieStr) { + return false + } + + const cookies = cookieStr + .split(';') + .map(x => x.split(/=(.*)$/s)) + .reduce((x, y) => { + x[decodeURIComponent(y[0].trim())] = decodeURIComponent(y[1].trim()) + return x + }, {}) + + if (!cookies.token || cookies.token !== token) { + return false + } + + return true +}